commit 1f5ecb6da4b91f4178427d30a8d0a80464741cad Author: Ильяс Султанов Date: Tue Jul 7 21:03:40 2026 +0300 fix(number-fields): избегаем потери точности длинных чисел - number-поля теперь рендерятся как text + inputMode=numeric, чтобы браузер не округлял значения через input type=number - пробелы при вставке в number-поля удаляются - бэкенд нормализует значения number-полей в строку перед сохранением - добавлен хелпер normalizeFieldValueForStorage Closes: искажение расчётного счёта и других длинных числовых полей diff --git a/.canvas/assets/asset_-1689877073.png b/.canvas/assets/asset_-1689877073.png new file mode 100644 index 0000000..04fdd0b Binary files /dev/null and b/.canvas/assets/asset_-1689877073.png differ diff --git a/.canvas/assets/asset_-893763085.png b/.canvas/assets/asset_-893763085.png new file mode 100644 index 0000000..8674f1a Binary files /dev/null and b/.canvas/assets/asset_-893763085.png differ diff --git a/.canvas/assets/asset_103195470.png b/.canvas/assets/asset_103195470.png new file mode 100644 index 0000000..a94f0ff Binary files /dev/null and b/.canvas/assets/asset_103195470.png differ diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..9b07336 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,18 @@ +node_modules/ +dist/ +.git/ +.local/ +*.png +*.tar.gz +mobile/node_modules/ +attached_assets/ +.env +.DS_Store + +# Server-side files not tracked in repo — prevent Docker build cache invalidation +backups/ +scripts/backup-*.sh +scripts/disk-check.sh +scripts/debug_*.ts +docker-compose.yml.bak.serverfix +*.log diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c5b7793 --- /dev/null +++ b/.env.example @@ -0,0 +1,113 @@ +# ============================================= +# Application secrets (required) +# ============================================= + +# JWT secrets — each token type has its own key (generate with: openssl rand -hex 32) +# FATAL: app will NOT start if these are missing +JWT_ACCESS_SECRET=your-access-secret-here +JWT_REFRESH_SECRET=your-refresh-secret-here +JWT_SUPERADMIN_SECRET=your-superadmin-secret-here +# Optional: separate secret for bot service tokens (falls back to JWT_ACCESS_SECRET) +# JWT_BOT_SECRET=your-bot-secret-here + +# Token expiry (optional — these are the defaults) +# JWT_ACCESS_EXPIRES=14d +# JWT_REFRESH_EXPIRES_REMEMBER=90d +# JWT_REFRESH_EXPIRES_SESSION=30d +# JWT_SUPERADMIN_EXPIRES=1h +# JWT_BOT_EXPIRES=10m + +# Session secret for express-session (generate with: openssl rand -hex 32) +SESSION_SECRET=your-session-secret-here + +# HMAC secret for API key hashing (generate with: openssl rand -hex 32) +# Required if using MCP/RAG API keys. Without it the server will throw on key creation/lookup. +API_KEY_HMAC_SECRET=your-api-key-hmac-secret-here + +# Web Push VAPID keys (generate with: npx web-push generate-vapid-keys) +VAPID_PUBLIC_KEY=your-vapid-public-key +VAPID_PRIVATE_KEY=your-vapid-private-key + +# SendGrid API key for transactional email (optional — app works without it) +# SENDGRID_API_KEY=SG.xxxxxxxxxxxx + +# ============================================= +# Database — choose ONE of the two modes below +# ============================================= + +# --- MODE 1: Neon Cloud (default) --- +# Run with: docker compose up --build +# Set DATABASE_URL to your Neon connection string: +DATABASE_URL=postgresql://user:password@host/dbname?sslmode=require + +# --- MODE 2: Self-hosted PostgreSQL --- +# Run with: docker compose --profile with-postgres up --build +# Set POSTGRES_* variables below AND update DATABASE_URL above to point +# to the Docker "db" service (e.g. postgresql://appuser:password@db:5432/appdb). +# DATABASE_URL is NOT auto-constructed — it must be set manually. + +# POSTGRES_DB=appdb +# POSTGRES_USER=appuser +# POSTGRES_PASSWORD=your-strong-db-password + +# ============================================= +# File storage — choose ONE of the two modes +# ============================================= + +# --- MODE 1: Local disk (default, Replit/dev) --- +# Files are stored in the uploads/ folder on disk. +# No extra variables needed. + +# --- MODE 2: MinIO S3 (recommended for production / "device in a box") --- +# Run with: docker compose --profile with-minio up --build +# The app automatically switches to S3 mode when MINIO_ENDPOINT is set. + +# MINIO_ENDPOINT=http://minio:9000 # use "minio" as hostname inside Docker network +# MINIO_ACCESS_KEY=minioadmin # same as MINIO_ROOT_USER in docker-compose +# MINIO_SECRET_KEY=your-strong-minio-password +# MINIO_BUCKET=files + +# ============================================= +# WebDAV (optional, only with with-minio profile) +# ============================================= +# Allows browsing/uploading files via Windows Explorer, Finder, Cyberduck +# Access: http://device-ip:8080 (user/pass below) + +# WEBDAV_USER=admin +# WEBDAV_PASSWORD=your-webdav-password + +# ============================================= +# Ollama / Local LLM (optional) +# ============================================= +# Base URL for Ollama server (default: http://localhost:11434) +# OLLAMA_BASE_URL=http://ollama:11434 + +# Number of CPU threads Ollama should use for inference. +# Default: (CPU cores - 1), e.g. 3 on a 4-core server. +# OLLAMA_NUM_THREAD=3 + +# Context window size (default: 2048). Increase only if you have enough RAM. +# OLLAMA_NUM_CTX=2048 + +# Allow private/loopback URLs for RAG providers (required for local Ollama) +# ALLOW_PRIVATE_RAG_URLS=true + +# ============================================= +# MCP API Keys (required for external MCP clients like n8n, Cursor, etc.) +# ============================================= +# HMAC secret for hashing API keys. Generate with: openssl rand -hex 32 +# FATAL: MCP key creation will fail if this is missing +# API_KEY_HMAC_SECRET=your-hmac-secret-here + +# ============================================= +# Public URL of CRM itself — used for presigned document URLs +# APP_URL=https://iistwin.ru + +# ============================================= +# Document Worker (optional — for PDF/DOCX generation) +# ============================================= +# URL of the document-worker microservice (Chromium + LibreOffice). +# Inside Docker Compose use the service name: +# DOC_WORKER_URL=http://document-worker:3000 +# For local development without Docker, you can point to an external service: +# DOC_WORKER_URL=http://localhost:3000 diff --git a/.env.local.example b/.env.local.example new file mode 100644 index 0000000..81cc7cf --- /dev/null +++ b/.env.local.example @@ -0,0 +1,71 @@ +# ============================================= +# Local development environment for iistwin +# Full copy of production, runs via docker-compose.local.yml +# +# Copy this file to .env.local and fill in secrets. +# .env.local is NOT committed to Git — sync it via Yandex Disk. +# ============================================= + +# JWT secrets — generate with: openssl rand -hex 32 +JWT_ACCESS_SECRET=LOCAL_ACCESS_SECRET_REPLACE_ME +JWT_REFRESH_SECRET=LOCAL_REFRESH_SECRET_REPLACE_ME +JWT_SUPERADMIN_SECRET=LOCAL_SUPERADMIN_SECRET_REPLACE_ME + +# Session secret — generate with: openssl rand -hex 32 +SESSION_SECRET=LOCAL_SESSION_SECRET_REPLACE_ME + +# HMAC secret for API key hashing — generate with: openssl rand -hex 32 +API_KEY_HMAC_SECRET=LOCAL_API_KEY_HMAC_SECRET_REPLACE_ME + +# Web Push VAPID keys — generate with: npx web-push generate-vapid-keys +VAPID_PUBLIC_KEY=LOCAL_VAPID_PUBLIC_KEY_REPLACE_ME +VAPID_PRIVATE_KEY=LOCAL_VAPID_PRIVATE_KEY_REPLACE_ME + +# ============================================= +# Database (local Docker PostgreSQL + pgvector) +# ============================================= +POSTGRES_DB=appdb +POSTGRES_USER=appuser +# Generate a strong local password with: openssl rand -hex 16 +POSTGRES_PASSWORD=local_db_password_replace_me + +DATABASE_URL=postgresql://appuser:local_db_password_replace_me@db:5432/appdb + +# ============================================= +# File storage (local MinIO) +# ============================================= +MINIO_ENDPOINT=http://minio:9000 +MINIO_ACCESS_KEY=minioadmin +# Generate a strong local password with: openssl rand -hex 16 +MINIO_SECRET_KEY=local_minio_secret_replace_me +MINIO_BUCKET=files + +# ============================================= +# WebDAV (local) +# ============================================= +WEBDAV_USER=admin +WEBDAV_PASSWORD=local_webdav_password_replace_me + +# ============================================= +# Ollama / Local LLM (optional, enable with --profile with-ollama) +# ============================================= +# OLLAMA_BASE_URL=http://ollama:11434 +# OLLAMA_NUM_THREAD=3 +# OLLAMA_NUM_CTX=2048 +ALLOW_PRIVATE_RAG_URLS=true + +# ============================================= +# Public URL of CRM itself +# ============================================= +APP_URL=http://localhost:5000 + +# ============================================= +# Document Worker +# ============================================= +DOC_WORKER_URL=http://document-worker:3000 + +# ============================================= +# HTTPS detection behind reverse proxy +# Local development: must be false +# ============================================= +APP_HTTPS_PROXY=false diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..9890024 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,53 @@ +# Автоматически определять текстовые файлы и нормализовать переводы строк к LF при коммите. +* text=auto eol=lf + +# Явно текстовые исходники (страховка от неоднозначного автоопределения). +*.ts text eol=lf +*.tsx text eol=lf +*.js text eol=lf +*.jsx text eol=lf +*.mjs text eol=lf +*.cjs text eol=lf +*.json text eol=lf +*.yml text eol=lf +*.yaml text eol=lf +*.md text eol=lf +*.css text eol=lf +*.scss text eol=lf +*.html text eol=lf +*.svg text eol=lf +*.sh text eol=lf +*.bash text eol=lf +*.sql text eol=lf +*.conf text eol=lf +*.cfg text eol=lf +*.ini text eol=lf +*.txt text eol=lf +*.env* text eol=lf + +# Явно бинарные форматы — не трогать переводы строк. +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary +*.webp binary +*.woff binary +*.woff2 binary +*.ttf binary +*.eot binary +*.otf binary +*.mp3 binary +*.mp4 binary +*.ogg binary +*.wav binary +*.webm binary +*.pdf binary +*.docx binary +*.xlsx binary +*.pptx binary +*.zip binary +*.gz binary +*.tar binary +*.rar binary +*.7z binary diff --git a/.githooks/pre-push b/.githooks/pre-push new file mode 100644 index 0000000..0f02111 --- /dev/null +++ b/.githooks/pre-push @@ -0,0 +1,21 @@ +#!/bin/sh +# Pre-push hook: run TypeScript check and build before every push. + +while read local_ref local_sha remote_ref remote_sha; do + : # no-op; pushes to main are allowed now + +done + +echo "Running npm run check..." +npm run check || { + echo "TypeScript check failed. Push aborted." + exit 1 +} + +echo "Running npm run build..." +npm run build || { + echo "Build failed. Push aborted." + exit 1 +} + +echo "All checks passed. Pushing..." diff --git a/.github/workflows/build-document-worker.yml b/.github/workflows/build-document-worker.yml new file mode 100644 index 0000000..dbd2c6f --- /dev/null +++ b/.github/workflows/build-document-worker.yml @@ -0,0 +1,57 @@ +name: Build and Push Document Worker + +on: + push: + branches: + - main + paths: + - 'server/documents/worker/**' + - 'docker/Dockerfile.documents' + - '.github/workflows/build-document-worker.yml' + workflow_dispatch: + +permissions: + contents: read + packages: write + +jobs: + build: + name: Build document-worker image + runs-on: ubuntu-latest + timeout-minutes: 50 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Docker meta + id: meta + uses: docker/metadata-action@v5 + with: + images: ghcr.io/${{ github.repository_owner }}/iistwin-document-worker + tags: | + type=sha,prefix=,suffix=,format=short + type=raw,value=latest,enable={{is_default_branch}} + + - name: Build and push + uses: docker/build-push-action@v5 + with: + context: . + file: docker/Dockerfile.documents + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + build-args: | + ALPINE_MIRROR=mirrors.aliyun.com + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..a10bf2e --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,171 @@ +name: Deploy to Server + +on: + push: + branches: + - main + schedule: + # Daily production deploy at 03:00 UTC. + # All feature branches merged into main during the day are deployed at this time. + - cron: '0 3 * * *' + workflow_dispatch: + # Manual deploy for emergencies only. + +jobs: + check: + name: Type Check & Build + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Type check + run: npm run check + + - name: Type check document worker + run: | + cd server/documents/worker + npm ci + npm run check + + - name: Build + run: npm run build + + deploy: + name: Deploy to VPS + needs: check + runs-on: ubuntu-latest + if: github.ref == 'refs/heads/main' + timeout-minutes: 50 + concurrency: + group: production-deploy + cancel-in-progress: false + + steps: + - name: Deploy via SSH + uses: appleboy/ssh-action@v1 + env: + GHCR_USERNAME: ${{ github.actor }} + GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + host: 82.202.130.22 + username: root + key: ${{ secrets.SSH_KEY }} + timeout: 60s + command_timeout: 45m + envs: GHCR_USERNAME,GHCR_TOKEN + script: | + set -e + cd /opt/crm + + # 1. Сохранить кастомизации сервера + git stash push -m 'server-customizations' \ + -- docker-compose.yml docker-entrypoint.sh \ + server/index.ts shared/schema.ts \ + client/src/services/auth.service.ts || true + + # 2. Получить ровно ту версию, что в GitHub (без merge-конфликтов) + git fetch origin main + git reset --hard origin/main + + # 3. Восстановить кастомизации + git stash pop || true + + # 4. Восстановить docker-compose из бэкапа (вне репозитория) + cp /opt/crm-server/docker-compose.yml.bak.serverfix docker-compose.yml + + # 5. Удалить старые/зависшие контейнеры, чтобы избежать + # Conflict: container name is already in use + docker compose stop app document-worker || true + docker compose rm -f app document-worker || true + docker ps -aq --filter "name=crm-app" | xargs -r docker rm -f || true + docker ps -aq --filter "name=crm-document-worker" | xargs -r docker rm -f || true + + # 5.5 Логин в GitHub Container Registry для скачивания prebuilt образа document-worker + echo "$GHCR_TOKEN" | docker login ghcr.io -u "$GHCR_USERNAME" --password-stdin + + # 5.6 Мягкая очистка: удалить старые контейнеры и dangling образы, + # но оставить build cache для ускорения сборки CRM. + docker system prune -f + + # 6. Собрать и запустить приложение. + # document-worker больше не собирается здесь — используется готовый образ из GHCR. + # BUILD_ARG инвалидирует слой сборки фронтенда, если код изменился, + # но позволяет Docker кэшировать слои npm ci. + # Retry 3 times to work around transient Alpine mirror I/O errors. + BUILD_SHA=${{ github.sha }} + BUILD_OK=false + for attempt in 1 2 3; do + echo "=== Docker build attempt $attempt ===" + if docker compose build --build-arg BUILD_SHA=$BUILD_SHA --build-arg ALPINE_MIRROR=mirrors.aliyun.com app; then + BUILD_OK=true + break + fi + echo "Build failed on attempt $attempt" + if [ "$attempt" -lt 3 ]; then + echo "Cleaning Docker cache and retrying in 30s..." + docker system prune -a -f + docker builder prune -f + sleep 30 + fi + done + if [ "$BUILD_OK" != "true" ]; then + echo "Docker build failed after 3 attempts" + exit 1 + fi + + # 6.5 Получить свежий prebuilt образ document-worker из GHCR + docker compose pull document-worker + + docker compose up -d app document-worker + + # 7. Дождаться healthcheck'ов + echo "Waiting for healthchecks..." + APP_HEALTHY=false + WORKER_HEALTHY=false + for i in $(seq 1 30); do + if [ "$APP_HEALTHY" != "true" ] && docker ps | grep crm-app | grep -q healthy; then + echo "iistwin app is healthy!" + APP_HEALTHY=true + fi + if [ "$WORKER_HEALTHY" != "true" ] && docker ps | grep crm-document-worker | grep -q healthy; then + echo "document-worker is healthy!" + WORKER_HEALTHY=true + fi + if [ "$APP_HEALTHY" = "true" ] && [ "$WORKER_HEALTHY" = "true" ]; then + break + fi + sleep 2 + done + + if [ "$APP_HEALTHY" != "true" ]; then + echo "App healthcheck failed" + exit 1 + fi + if [ "$WORKER_HEALTHY" != "true" ]; then + echo "Document worker healthcheck failed" + exit 1 + fi + + # 8. Мягкая очистка Docker: оставляем build cache и tagged базовые образы, + # чтобы следующий деплой переиспользовал слои npm ci и сборки. + # Полная очистка запускается ежедневно через cron (disk-check.sh / docker-cleanup.sh). + docker system prune -f + docker builder prune -f --keep-storage 5gb + + # 9. Удалить старые образы crm-app, чтобы они не копились между деплоями. + # Текущий образ используется запущенным контейнером, поэтому он не удалится. + CURRENT_IMAGE_ID=$(docker inspect --format='{{.Image}}' crm-app-1) + docker images crm-app --format "{{.ID}}" | grep -v "$CURRENT_IMAGE_ID" | xargs -r docker rmi -f || true + + echo "Cleanup completed" diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml new file mode 100644 index 0000000..89cc3d8 --- /dev/null +++ b/.github/workflows/pr-check.yml @@ -0,0 +1,39 @@ +name: Branch Check + +on: + push: + branches-ignore: + - main + pull_request: + branches: + - main + +jobs: + check: + name: Type Check & Build + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Type check + run: npm run check + + - name: Type check document worker + run: | + cd server/documents/worker + npm ci + npm run check + + - name: Build + run: npm run build diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e347881 --- /dev/null +++ b/.gitignore @@ -0,0 +1,31 @@ +node_modules +dist +.DS_Store +server/public +vite.config.ts.* +*.tar.gz +mobile/credentials/ +mobile/credentials.json +mobile/node_modules/ +.env +.env.local +.env.*.local +tmp/crm-prod-sync.* + +# Debug screenshots — кидай сюда все временные скрины, в репо не попадут +screenshots/ +*.png +*.jpg +*.jpeg +*.webp +*.gif + +# Локальная конфигурация finance модуля (содержит credentials) +data/ + +# Разрешаем нужные assets проекта +!client/public/** +!client/src/assets/** +!mobile/assets/** +!attached_assets/** +!.canvas/assets/** diff --git a/.replit b/.replit new file mode 100644 index 0000000..30fd5b9 --- /dev/null +++ b/.replit @@ -0,0 +1,54 @@ +modules = ["nodejs-20", "web", "python-3.11", "postgresql-16"] +run = "npm run dev" +hidden = [".config", ".git", "generated-icon.png", "node_modules", "dist"] + +[nix] +channel = "stable-24_05" +packages = ["imagemagick", "jdk17", "expect"] + +[deployment] +deploymentTarget = "autoscale" +build = ["npm", "run", "build"] +run = ["sh", "-c", "NODE_ENV=production node dist/index.js"] + +[env] +PORT = "5000" + +[agent] +integrations = ["javascript_sendgrid:1.0.0", "javascript_database:1.0.0"] + +[workflows] +runButton = "Project" + +[[workflows.workflow]] +name = "Project" +mode = "parallel" +author = "agent" + +[[workflows.workflow.tasks]] +task = "workflow.run" +args = "Start application" + +[[workflows.workflow]] +name = "Start application" +author = "agent" + +[[workflows.workflow.tasks]] +task = "shell.exec" +args = "npm run dev" +waitForPort = 5000 + +[[ports]] +localPort = 5000 +externalPort = 80 + +[userenv] + +[userenv.shared] +VAPID_PUBLIC_KEY = "BIciI35ELG1CcSkpcrguGVsRYn26iqyE1Gatisjo2gKT7q5eGkMzp1xMiRS_Ded2T5EcJzisorvxPKWuyPna894" +VAPID_PRIVATE_KEY = "C45jDHzSUc2criqL8ccPwZYXtqCMLujMdZ6I0nh4qGY" +PUSHY_API_KEY = "8954cb06931b4d6bf519add9cd22f2b4c02780eb090cd8c3244013f94e8b966d" + +[postMerge] +path = "scripts/post-merge.sh" +timeoutMs = 20000 diff --git a/DOCKER.md b/DOCKER.md new file mode 100644 index 0000000..3309049 --- /dev/null +++ b/DOCKER.md @@ -0,0 +1,280 @@ +# Запуск через Docker + +Приложение поддерживает несколько режимов развёртывания через Docker Compose. +Каждый режим активируется профилями `--profile`. + +--- + +## Режимы базы данных + +| Режим | База данных | Команда | +|---|---|---| +| Внешняя (Neon, любой Postgres) | Ваш хост | `docker compose up --build` | +| Встроенная PostgreSQL | Docker-контейнер | `docker compose --profile with-postgres up --build` | + +## Режимы хранилища файлов + +| Режим | Хранилище | Команда | +|---|---|---| +| Локальный диск (по умолчанию) | Папка `uploads/` | *(без флагов)* | +| MinIO S3 + WebDAV | MinIO-контейнер | `--profile with-minio` | + +Профили можно комбинировать. Самый полный вариант («устройство в коробке»): +```bash +docker compose --profile with-postgres --profile with-minio up --build +``` + +--- + +## Режим 1: Внешняя база, локальный диск + +Файлы хранятся в папке `uploads/` (сбрасываются при пересборке). + +```env +DATABASE_URL=postgresql://user:password@host/dbname?sslmode=require +JWT_SECRET=... +SESSION_SECRET=... +API_KEY_HMAC_SECRET=... +VAPID_PUBLIC_KEY=... +VAPID_PRIVATE_KEY=... +``` + +```bash +docker compose up --build +``` + +--- + +## Режим 2: Встроенная PostgreSQL, локальный диск + +```env +# PostgreSQL +POSTGRES_DB=appdb +POSTGRES_USER=appuser +POSTGRES_PASSWORD=your-strong-db-password +DATABASE_URL=postgresql://appuser:your-strong-db-password@db:5432/appdb + +# App +JWT_SECRET=... +SESSION_SECRET=... +API_KEY_HMAC_SECRET=... +VAPID_PUBLIC_KEY=... +VAPID_PRIVATE_KEY=... +``` + +```bash +docker compose --profile with-postgres up --build +``` + +--- + +## Режим 3: MinIO S3 + WebDAV (рекомендуется для устройств) + +Файлы хранятся в MinIO — не теряются при перезапуске. WebDAV позволяет +просматривать и добавлять файлы через Windows Explorer, Finder, Cyberduck. + +### Переменные окружения + +```env +# PostgreSQL (если используете встроенную) +POSTGRES_DB=appdb +POSTGRES_USER=appuser +POSTGRES_PASSWORD=your-strong-db-password +DATABASE_URL=postgresql://appuser:your-strong-db-password@db:5432/appdb + +# MinIO S3 — приложение видит MinIO как "minio" внутри Docker-сети +MINIO_ENDPOINT=http://minio:9000 +MINIO_ACCESS_KEY=minioadmin +MINIO_SECRET_KEY=your-strong-minio-password +MINIO_BUCKET=files + +# WebDAV — доступ через браузер/файловый менеджер на порту 8080 +WEBDAV_USER=admin +WEBDAV_PASSWORD=your-webdav-password + +# App +JWT_SECRET=... +SESSION_SECRET=... +API_KEY_HMAC_SECRET=... +VAPID_PUBLIC_KEY=... +VAPID_PRIVATE_KEY=... +``` + +### Запуск (всё сразу: PostgreSQL + App + MinIO + WebDAV) + +```bash +docker compose --profile with-postgres --profile with-minio up --build +``` + +### Доступные интерфейсы + +| Сервис | Адрес | Описание | +|---|---|---| +| iistwin приложение | `http://device-ip:5000` | Основной интерфейс | +| MinIO Console | `http://device-ip:9001` | Веб-интерфейс MinIO (логин: MINIO_ACCESS_KEY) | +| WebDAV | `http://device-ip:8080` | Файловый менеджер (логин: WEBDAV_USER) | + +### Подключение WebDAV + +**Windows Explorer:** Нажмите «Подключить сетевой диск» → введите `\\device-ip@8080\DavWWWRoot` + +**macOS Finder:** Нажмите ⌘K → введите `http://device-ip:8080` + +**Cyberduck / WinSCP:** Протокол WebDAV, хост `device-ip`, порт `8080` + +--- + +## Локальные LLM через Ollama + +Профиль `with-ollama` запускает [Ollama](https://ollama.com/) — локальный LLM-сервер — как отдельный контейнер. Модели **не скачиваются автоматически**: вы выбираете и загружаете их через интерфейс iistwin в разделе **Настройки → LLM Провайдеры**. + +### Запуск с Ollama + +```bash +# Только приложение + Ollama (внешняя БД) +docker compose --profile with-ollama up --build + +# Всё сразу: PostgreSQL + App + Ollama +docker compose --profile with-postgres --profile with-ollama up --build + +# Максимальная конфигурация: PostgreSQL + MinIO + WebDAV + Ollama +docker compose --profile with-postgres --profile with-minio --profile with-ollama up --build +``` + +При активном профиле `with-ollama` переменная `OLLAMA_BASE_URL=http://ollama:11434` передаётся в контейнер приложения автоматически через `docker-compose.yml`. Переопределить адрес можно через `.env`. + +### Управление моделями через UI + +После запуска откройте **Настройки → LLM Провайдеры**, добавьте провайдер типа `Ollama` с URL `http://ollama:11434` (или `http://localhost:11434` для локального запуска вне Docker) и воспользуйтесь разделом **«Модели Ollama»**: + +- **Скачать модель** — введите имя (например `qwen2.5:7b`) и нажмите «Скачать». Сервер запустит `ollama pull`, скачивание занимает несколько минут. +- **Удалить модель** — нажмите кнопку удаления рядом с установленной моделью. +- **Список установленных моделей** — отображается с размером на диске. + +### Рекомендуемые модели + +| Тип | Модели | +|---|---| +| Эмбеддинги (RAG) | `bge-m3`, `mxbai-embed-large`, `qwen3-embedding:8b` | +| Чат / AI-боты | `qwen2.5:3b`, `qwen2.5:7b` | + +### NVIDIA GPU + +Для использования GPU раскомментируйте секцию `deploy` в сервисе `ollama` в файле `docker-compose.yml` и убедитесь, что на хосте установлен [NVIDIA Container Toolkit](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/install-guide.html). + +### Порты + +| Сервис | Порт | Описание | +|---|---|---| +| Ollama API | `11434` | REST API для управления моделями и инференса | + +### Volumes + +| Volume | Данные | +|---|---| +| `ollama_data` | Скачанные модели Ollama | + +Для сброса данных (удаление всех скачанных моделей): +```bash +docker compose --profile with-ollama down -v +``` + +--- + +## Генерация VAPID-ключей для веб-пушей + +VAPID-ключи используются для web push уведомлений. Без постоянных ключей при каждом перезапуске сервера все push-подписки браузеров становятся невалидными. + +**Генерация ключей (выполнить один раз):** +```bash +npx web-push generate-vapid-keys +``` + +Команда выведет пару ключей: +``` +Public Key: +BAxxx... + +Private Key: +xxx... +``` + +Скопируйте значения в переменные окружения `VAPID_PUBLIC_KEY` и `VAPID_PRIVATE_KEY`. Если ключи не заданы, сервер выведет предупреждение в лог и сгенерирует временные ключи (только для текущей сессии). + +--- + +## Sequence при старте + +Независимо от режима, контейнер приложения всегда: + +1. Проверяет доступность базы данных (`scripts/check-db.js`) +2. Применяет SQL-миграции из папки `migrations/` (`npm run db:migrate`) +3. Создаёт MinIO bucket если настроен S3 (автоматически) +4. Запускает сервер приложения (`npm run start`) + +--- + +## Изменение схемы базы данных + +При добавлении новых таблиц или колонок в `shared/schema.ts` необходимо +создать SQL-файл миграции, который будет применён на всех серверах автоматически. + +### Шаги + +1. **Внесите изменения** в `shared/schema.ts`. + +2. **Сгенерируйте миграцию** (создаёт SQL-файл в папке `migrations/`): + ```bash + npm run db:generate + ``` + Команда создаст файл вида `migrations/0004_.sql`. + +3. **Закоммитьте оба файла** — изменённый `shared/schema.ts` и новый SQL-файл миграции: + ```bash + git add shared/schema.ts migrations/ + git commit -m "feat: add " + ``` + +4. **При деплое новой версии** (`docker compose pull && docker compose up -d`) + `db:migrate` автоматически применит новую миграцию, не затрагивая уже существующие данные. + +> **Важно:** SQL-файлы миграций нельзя редактировать после коммита — только добавлять новые. +> Применённые миграции отслеживаются в таблице `__drizzle_migrations` в самой БД. + +### Проверка миграций после деплоя + +Чтобы убедиться, что все миграции применились корректно, выполните в PostgreSQL: + +```sql +SELECT * FROM "__drizzle_migrations" ORDER BY created_at; +``` + +Ожидаемый результат: строки `0000_baseline`, `0001_sync_missing_columns`, …, `0007_sync_missing_columns_v2` (плюс все последующие). +Если строки отсутствуют или возникла ошибка — смотрите логи контейнера: `docker compose logs app`. + +--- + +## Хранение данных (volumes) + +| Volume | Данные | +|---|---| +| `postgres_data` | База данных PostgreSQL | +| `minio_data` | Файлы (загруженные через iistwin) | +| `ollama_data` | Скачанные LLM-модели | + +Для полного сброса данных: +```bash +docker compose --profile with-postgres --profile with-minio --profile with-ollama down -v +``` + +--- + +## Быстрый справочник + +| Цель | Команда | +|---|---| +| Только приложение (внешняя БД + диск) | `docker compose up --build` | +| + встроенная PostgreSQL | `docker compose --profile with-postgres up --build` | +| + MinIO + WebDAV (без PostgreSQL) | `docker compose --profile with-minio up --build` | +| + локальные LLM (Ollama) | `docker compose --profile with-ollama up --build` | +| Всё сразу | `docker compose --profile with-postgres --profile with-minio --profile with-ollama up --build` | diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7211241 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,45 @@ +FROM node:20-alpine AS builder + +WORKDIR /app + +COPY package*.json ./ +RUN --mount=type=cache,target=/root/.npm npm ci + +COPY . . +ARG BUILD_SHA=unknown +ENV BUILD_SHA=$BUILD_SHA +RUN npm run build + +FROM node:20-alpine AS production + +# Optional mirror for Alpine packages (helps avoid transient I/O errors on dl-cdn) +ARG ALPINE_MIRROR= +RUN if [ -n "$ALPINE_MIRROR" ]; then sed -i "s|dl-cdn.alpinelinux.org|$ALPINE_MIRROR|g" /etc/apk/repositories; fi + +WORKDIR /app + +COPY --from=builder /app/package*.json ./ +COPY --from=builder /app/node_modules ./node_modules +COPY --from=builder /app/dist ./dist +COPY --from=builder /app/shared ./shared +COPY --from=builder /app/drizzle.config.ts ./drizzle.config.ts +COPY --from=builder /app/docker-entrypoint.sh ./docker-entrypoint.sh +COPY --from=builder /app/scripts ./scripts +COPY --from=builder /app/server/scripts ./server/scripts + +RUN chmod +x docker-entrypoint.sh +RUN mkdir -p /app/data + +# Python + python-docx для обработки DOCX-шаблонов +RUN apk add --no-cache python3 py3-pip py3-lxml \ + && pip3 install --break-system-packages python-docx \ + && rm -rf /root/.cache/pip + +EXPOSE 5000 + +HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \ + CMD wget -qO- http://localhost:5000/api/health || exit 1 + +COPY --from=builder /app/migrations ./migrations +ENTRYPOINT ["./docker-entrypoint.sh"] + diff --git a/FIELD_TYPES.md b/FIELD_TYPES.md new file mode 100644 index 0000000..5bbe05e --- /dev/null +++ b/FIELD_TYPES.md @@ -0,0 +1,165 @@ +# Реестр типов полей iistwin + +> Единый источник правды: `shared/field-types.ts` +> Дата актуализации: 2026-05-18 + +--- + +## Сводная таблица всех типов + +| # | ID | Название | Иконка (Lucide) | Опции | Формы | Глобальные | Таблицы | +|---|-----|----------|-----------------|-------|:-----:|:----------:|:-------:| +| 1 | `text` | Текстовое поле | `Type` | ❌ | ✅ | ✅ | ✅ | +| 2 | `number` | Числовое поле | `Hash` | ❌ | ✅ | ✅ | ✅ | +| 3 | `date` | Дата | `Calendar` | ❌ | ✅ | ✅ | ✅ | +| 4 | `datetime` | Дата и время | `Clock` | ❌ | ✅ | ✅ | ✅ | +| 5 | `select` | Выпадающий список | `ChevronDown` | ✅ | ✅ | ✅ | ✅ | +| 6 | `checkbox` | Чекбокс | `CheckSquare` | ❌ | ✅ | ✅ | ✅ | +| 7 | `toggle` | Переключатель | `ToggleLeft` | ❌ | ✅ | ✅ | ✅ | +| 8 | `button` | Кнопка | `MousePointer` | ❌ | ✅ | ❌ | ❌ | +| 9 | `user` | Выбор пользователя | `Users` | ❌ | ✅ | ✅ | ✅ | +| 10 | `file` | Загрузка файлов | `Paperclip` | ❌ | ✅ | ✅ | ✅ | +| 11 | `textarea` | Многострочный текст | `AlignLeft` | ❌ | ✅ | ✅ | ✅ | +| 12 | `rich-text` | Богатый текстовый редактор | `FileText` | ❌ | ✅ | ✅ | ✅ | +| 13 | `history-number` | Числовое поле с историей | `History` | ❌ | ❌ | ❌ | ✅ | +| 14 | `table` | Выбор из таблицы | `Table` | ❌ | ❌ | ❌ | ✅ | +| 15 | `task` | Связь с задачей | `Link2` | ❌ | ✅ | ❌ | ✅ | +| 16 | `company` | Организация (DaData) | `Building2` | ❌ | ✅ | ✅ | ✅ | +| 17 | `geo` | Адрес с координатами | `MapPin` | ❌ | ✅ | ✅ | ✅ | +| 18 | `formula` | Формула | `Calculator` | ❌ | ❌ | ❌ | ✅ | + +**Легенда:** +- **Опции** — поддерживает ли тип предустановленный список значений (только `select`) +- **Формы** — можно создать поле этого типа в редакторе форм +- **Глобальные** — можно создать reusable шаблон глобального поля +- **Таблицы** — можно использовать как тип колонки в справочнике/таблице + +--- + +## Использование по контекстам + +### 1. Формы (`FORM_FIELD_TYPES`) — 17 типов + +Файлы: +- `client/src/pages/FormEditor.tsx` — редактор форм +- `client/src/pages/TaskDetail.tsx` — карточка задачи +- `server/mcp.ts` → tool `add_form_field` + +| Тип | Особенности | +|-----|-------------| +| `button` | Только в формах. Действия: `open_url`, `create_task` | +| `task` | Связь задача→задача (parent/child). `taskRelationType` | +| `company` | Интеграция DaData (автозаполнение ИНН, названия) | +| `geo` | Адрес с координатами, интеграция карт | +| `file` | Загрузка в MinIO S3. `maxFileCount`, `maxFileSizeMB` | +| `select` | Единственный тип с `supportsOptions: true` | + +--- + +### 2. Глобальные поля (`GLOBAL_FIELD_TYPES`) — 16 типов + +Файлы: +- `client/src/pages/GlobalFields.tsx` — UI управления +- `server/mcp.ts` → tool `create_global_field` + +**Исключены:** `button`, `history-number`, `formula` + +Глобальные поля — это reusable шаблоны. Создаются один раз, затем вставляются в любую форму через `add_global_field_to_form` или `add_globals_to_table_tab`. + +--- + +### 3. Таблицы / Справочники (`TABLE_FIELD_TYPES`) — 16 типов + +Файлы: +- `client/src/pages/Tables.tsx` — страница справочников +- `client/src/components/tabs/types.ts` — типизация колонок +- `server/mcp.ts` → tool `add_globals_to_table_tab` + +**Исключён:** `button` + +| Тип | Особенности | +|-----|-------------| +| `history-number` | Хранит историю изменений значения (audit trail) | +| `formula` | Вычисляемое поле. Движок: `client/src/lib/formulaEngine.ts` | +| `table` | Ссылка на другой справочник (foreign key) | + +--- + +### 4. Пользовательские поля (User Custom Fields) — 6 типов ⚠️ + +Файлы: +- `shared/schema.ts:698` +- `client/src/pages/Users.tsx` + +```ts +z.enum(["text", "number", "date", "select", "user", "checkbox"]) +``` + +**Проблема:** не синхронизирован с `shared/field-types.ts`. Нет: `datetime`, `toggle`, `file`, `textarea`, `rich-text`, `company`, `geo`. + +--- + +### 5. API эндпоинт + +``` +GET /api/field-types +``` + +Возвращает полный реестр с метаданными: +```json +{ + "success": true, + "fieldTypes": [ + { "id": "text", "label": "Текстовое поле", "iconName": "Type", "supportsOptions": false, "allowedIn": ["form","global","table"] }, + ... + ] +} +``` + +--- + +### 6. MCP Tools + +| Tool | Допустимые типы | +|------|-----------------| +| `add_form_field` | `FORM_FIELD_TYPES` (17 типов) | +| `create_global_field` | `GLOBAL_FIELD_TYPES` (16 типов) | +| `add_globals_to_table_tab` | фильтрует по `TABLE_FIELD_TYPES` | + +--- + +## Техдолг и планы + +| Место | Проблема | Приоритет | +|-------|----------|-----------| +| `User Custom Fields` | 6 hardcoded типов, не использует `shared/field-types.ts` | 🔴 Высокий | +| `client/src/lib/fieldTypes.ts` | Дублирует реестр (React-иконки Lucide). Можно сделать динамический импорт | 🟡 Средний | +| `shared/schema.ts:1474` | Hardcoded union `'text' \| 'number' \| ...` | 🟡 Средний | + +--- + +## Как добавить новый тип поля + +### Шаг 1. Реестр +Добавить в `shared/field-types.ts`: +```ts +{ id: "rating", label: "Рейтинг", iconName: "Star", supportsOptions: false, allowedIn: ["form", "global", "table"] } +``` + +### Шаг 2. Иконка (если нужна на фронте) +Добавить в `client/src/lib/fieldTypes.ts`: +```ts +import { Star } from 'lucide-react'; +// ... +{ value: 'rating', label: 'Рейтинг', icon: Star }, +``` + +### Шаг 3. Рендерер (если нужна кастомная логика) +- `FormEditor.tsx` — UI в редакторе форм +- `TaskDetail.tsx` — отображение в карточке задачи + +### Шаг 4. Бэкенд (если нужна кастомная валидация) +- `server/services/form.service.ts` — валидация значений +- `server/services/task.service.ts` — обработка при создании/обновлении + +> Всё остальное (MCP, `/api/field-types`, `GlobalFields.tsx`, `Tables.tsx`, `Users.tsx`) синхронизируется **автоматически**. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..2871dd5 --- /dev/null +++ b/LICENSE @@ -0,0 +1,7 @@ +Copyright (c) 2026 Султанов Ильяс + +All rights reserved. This software and its source code are the proprietary +information of the copyright holder. No part of this software may be +reproduced, distributed, or transmitted in any form or by any means, +including photocopying, recording, or other electronic or mechanical +methods, without the prior written permission of the copyright holder. diff --git a/README.md b/README.md new file mode 100644 index 0000000..1da9943 --- /dev/null +++ b/README.md @@ -0,0 +1,89 @@ +# iistwin + +[![CI](https://github.com/iisultanov/iistwin/actions/workflows/ci.yml/badge.svg)](https://github.com/iisultanov/iistwin/actions/workflows/ci.yml) +![Node.js](https://img.shields.io/badge/node-%3E%3D20-brightgreen?logo=node.js&logoColor=white) +![License](https://img.shields.io/badge/license-MIT-blue) +![PostgreSQL](https://img.shields.io/badge/postgres-%3E%3D15-336791?logo=postgresql&logoColor=white) +![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen) + +A multitenant corporate iistwin platform with a React frontend and an Express backend. It provides organizations with independent management of users, projects, and tasks on shared infrastructure, featuring data isolation, real-time communication, and customizable workflow automation. + +## Quickstart + +```bash +cp .env.example .env # fill in DATABASE_URL, JWT_SECRET, SESSION_SECRET +npm install && npm run db:push && npm run dev +``` + +The app will be available at `http://localhost:5000`. + +## Key Features + +- Custom forms with configurable statuses, transitions, and conditional approvals +- Hierarchical subtasks with unlimited nesting +- Real-time chat with `@mentions` and Server-Sent Events +- Inline-editable data tables with Excel import/export +- JavaScript-powered custom tabs and layout components +- Event-driven notification system (in-app, email, push) +- Multi-tenant billing management +- Progressive Web App (PWA) support + +## Getting Started + +### Prerequisites + +- Node.js 20+ +- PostgreSQL 15+ (or use the built-in Docker Postgres profile — see below) + +### Local development (without Docker) + +1. Copy the environment template and fill in the required values: + + ```bash + cp .env.example .env + ``` + +2. Install dependencies: + + ```bash + npm install + ``` + +3. Push the database schema: + + ```bash + npm run db:push + ``` + +4. Start the development server: + + ```bash + npm run dev + ``` + + The app will be available at `http://localhost:5000`. + +## Running with Docker + +For Docker-based setup (including a built-in managed Postgres option), see **[DOCKER.md](./DOCKER.md)**. + +It covers: + +- **Mode 1** — connecting to an external database (e.g. Neon Cloud) +- **Mode 2** — letting Docker spin up a local Postgres 16 container alongside the app + +## Environment Variables + +See `.env.example` for all required environment variables. At minimum you will need: + +| Variable | Description | +|---|---| +| `DATABASE_URL` | PostgreSQL connection string | +| `JWT_SECRET` | Secret used to sign access tokens | +| `SESSION_SECRET` | Secret used for session cookies | +| `VAPID_PUBLIC_KEY` | Web Push VAPID public key | +| `VAPID_PRIVATE_KEY` | Web Push VAPID private key | + +## Super Admin + +A system-level super admin panel is available at `/superadmin`. The first super admin account can be created via `POST /api/superadmin/seed` (optionally protected by the `SUPERADMIN_SEED_TOKEN` environment variable). diff --git a/SESSION_NOTES.md b/SESSION_NOTES.md new file mode 100644 index 0000000..67bb578 --- /dev/null +++ b/SESSION_NOTES.md @@ -0,0 +1,70 @@ +# Заметки по сессии — 15.05.2026 + +## 1. Geo-поле (Адрес с координатами) +- **Статус:** ✅ Реализовано и задеплоено +- **Компонент:** `client/src/components/GeoField.tsx` +- **API:** `/api/dadata/suggest/address`, `/api/dadata/geolocate` +- **Формат данных:** `{ address: string, lat: number | string, lng: number | string }` +- **Особенности:** совмещённое поле координат ("55.707339, 52.315169"), подсказки DaData, обратное геокодирование + +## 2. Исправление ошибки создания задач (bad_request) +- **Причина:** `drizzle-zod@0.7.0` создаёт `strict()` схемы. `insertTaskSchema` не ожидал `customFields`, и zod отклонял запрос. +- **Фикс:** добавлено `customFields: z.record(z.any()).optional()` в `shared/schema.ts` +- **Файл:** `shared/schema.ts`, `insertTaskSchema` + +## 3. UI поля "Связь с формой" (task) +- **Проблема:** непонятные подписи "Родительская/дочерняя задача" +- **Решение:** переименовано в "Роль связанной задачи" + добавлены поясняющие описания под каждым вариантом +- **Файл:** `client/src/pages/FormEditor.tsx` + +## 4. Очистка Docker +- **Освобождено:** ~4 GB +- **До:** 49 GB, 42 GB использовано (86%) +- **После:** 49 GB, 24 GB использовано (50%) +- **Удалено:** dangling образы iistwin (crm-app-builder, crm-app старые), postgres:16-alpine, build cache + +## 5. Удаление заглушек из блока "Финансы" +- **Удалены файлы:** + - `client/src/pages/finance/profitability.tsx` + - `client/src/pages/finance/debt.tsx` + - `client/src/pages/finance/calculations.tsx` + - `client/src/pages/finance/planning.tsx` + - `client/src/pages/finance/audit.tsx` + - `client/src/pages/finance/heatmap.tsx` +- **Убраны:** импорты из `App.tsx`, роуты, экспорты из `index.ts` +- **FinanceSidebar:** оставлены только реальные страницы (Дашборд, Доходы, Расходы, Зарплата, Настройки) +- **FinanceNav:** убраны дубли (Доходы, Расходы, Зарплата, Настройки), оставлены Обзор + Категории доходов/расходов + +## 6. Настройка SSH для git на сервере +- **Сгенерирован ключ:** `/root/.ssh/id_ed25519` (ed25519) +- **Добавлен в GitHub:** Settings → SSH keys +- **Git remote:** `git@github.com:iisultanov/iistwin.git` +- **Результат:** `git pull` работает автоматически без пароля + +## Кастомизации сервера (НЕ коммитить в GitHub) +| Файл | Изменения | +|------|-----------| +| `docker-compose.yml` | Traefik-метки, `APP_HTTPS_PROXY=true`, сеть `n8n_proxy` | +| `server/index.ts` | `contentSecurityPolicy: false`, `hsts: false`, conditional HSTS | +| `docker-entrypoint.sh` | `echo "y" \| npx drizzle-kit push` + `CREATE EXTENSION vector` | +| `.env` | Секреты, `ALLOW_PRIVATE_RAG_URLS=true`, `API_KEY_HMAC_SECRET` | + +## Команды деплоя +```bash +ssh root@82.202.130.22 + +cd /opt/crm +git pull origin main +docker compose up -d --build +``` + +## Проверка статуса +```bash +curl -s https://iistwin.ru/api/health +``` + +## Доступы +- **SSH:** `root@82.202.130.22` (ключ: `C:\Users\User\Yandex.Disk\1kimi\срм\.ssh_id_ed25519_crm`) +- **iistwin:** https://iistwin.ru +- **GitHub:** https://github.com/iisultanov/iistwin +- **MinIO:** http://82.202.130.22:9001 diff --git a/_tablefield_orig.txt b/_tablefield_orig.txt new file mode 100644 index 0000000..66a6bad --- /dev/null +++ b/_tablefield_orig.txt @@ -0,0 +1,141 @@ + +> function TableFieldSelect({ formField, value, onChange }: { + formField: FormFieldData; + value: any; + onChange: (value: string) => void; + }) { + const options = formField.options && !Array.isArray(formField.options) ? formField.options : {}; + const tableId = options.tableId; + const displayColumn = options.displayColumn || ''; + + const { data: tableData, isLoading, error } = useQuery<{ table: { rows: Array<{ id: number; values: string[] }>; co +lumns: Array<{ name: string }> } }>({ + queryKey: ['/api/directories', tableId], + enabled: !!tableId, + staleTime: 5 * 60 * 1000, + gcTime: 10 * 60 * 1000, + retry: false, + }); + + const [searchTerm, setSearchTerm] = useState(''); + const [isOpen, setIsOpen] = useState(false); + const inputRef = useRef(null); + + if (!tableId) { + return
Таблица не настроена
; + } + + if (isLoading) { + return ; + } + + if (error) { + return
Нет доступа к таблице
; + } + + const table = tableData?.table; + if (!table || !table.rows?.length) { + return
Таблица пуста
; + } + + // Находим индекс столбца для отображения, с fallback на первый столбец + let displayColumnIndex = table.columns?.findIndex(c => c.name === displayColumn); + if (displayColumnIndex === undefined || displayColumnIndex < 0) { + displayColumnIndex = 0; + } + + const getDisplayValue = (rowId: string | number) => { + const row = table.rows?.find(r => r.id.toString() === rowId?.toString()); + return row?.values[displayColumnIndex!] || row?.values[0] || ''; + }; + + const selectedRow = table.rows?.find(r => r.id.toString() === value?.toString()); + const selectedDisplay = selectedRow ? (selectedRow.values[displayColumnIndex!] || selectedRow.values[0] || '') : '' +; + + const sortedRows = useMemo(() => { + return [...table.rows].sort((a, b) => { + const av = a.values[displayColumnIndex!] || a.values[0] || ''; + const bv = b.values[displayColumnIndex!] || b.values[0] || ''; + return av.localeCompare(bv, 'ru'); + }); + }, [table.rows, displayColumnIndex]); + + const filtered = useMemo(() => { + if (!searchTerm.trim()) return sortedRows; + const term = searchTerm.toLowerCase(); + return sortedRows.filter(r => { + const display = r.values[displayColumnIndex!] || r.values[0] || ''; + return display.toLowerCase().includes(term); + }); + }, [sortedRows, searchTerm, displayColumnIndex]); + + const handleSelect = (rowId: string) => { + onChange(rowId); + setSearchTerm(''); + setIsOpen(false); + }; + + return ( + + +
+ { + setSearchTerm(e.target.value); + setIsOpen(true); + }} + onFocus={() => { + setSearchTerm(''); + setIsOpen(true); + }} + data-testid={`select-table-${formField.code}`} + /> +
+
+ e.preventDefault()} + onInteractOutside={(e) => { + if (inputRef.current && inputRef.current.contains(e.target as Node)) { + e.preventDefault(); + } + }} + > +
    +
  • { e.preventDefault(); handleSelect(''); }} + > + — +
  • + {filtered.map(row => ( +
  • { e.preventDefault(); handleSelect(row.id.toString()); }} + > +
    + + {row.values[displayColumnIndex!] || row.values[0]} + + + ))} + {filtered.length === 0 && ( +
  • Нет совпадений
  • + )} + + + + ); + } + + // Компонент выбора связанной задачи из другой формы + function TaskFieldSelect({ formField, value, onChange }: { + + diff --git a/attached_assets/1_module_1767958951103.json b/attached_assets/1_module_1767958951103.json new file mode 100644 index 0000000..4658856 --- /dev/null +++ b/attached_assets/1_module_1767958951103.json @@ -0,0 +1,37 @@ +{ + "type": "1", + "label": "гугля", + "config": { + "title": "Google Таблица", + "hideTitle": true, + "componentType": "embed", + "embedFullHeight": true, + "embedStyle": { + "margin": "0", + "padding": "0", + "border": "none", + "width": "100%", + "height": "100%" + }, + "containerStyle": { + "margin": "0", + "padding": "0", + "overflow": "hidden" + } + }, + "inputSchema": [ + { + "name": "embedUrl", + "type": "url", + "label": "URL таблицы", + "required": true, + "placeholder": "https://docs.google.com/spreadsheets/d/..." + }, + { + "name": "embedHeight", + "type": "number", + "label": "Высота (px)", + "default": 500 + } + ] +} diff --git a/attached_assets/2025-09-16_13-52-59_1758020005216.png b/attached_assets/2025-09-16_13-52-59_1758020005216.png new file mode 100644 index 0000000..d34d88b Binary files /dev/null and b/attached_assets/2025-09-16_13-52-59_1758020005216.png differ diff --git a/attached_assets/Pasted---1766085687413_1766085687414.txt b/attached_assets/Pasted---1766085687413_1766085687414.txt new file mode 100644 index 0000000..493ce86 --- /dev/null +++ b/attached_assets/Pasted---1766085687413_1766085687414.txt @@ -0,0 +1,31 @@ +Чтобы не “класть” систему, нужно перестать грузить все задачи и переносить тяжёлые операции (фильтр/сорт/агрегации) на сервер: добавить пагинацию, облегчённые представления данных и кэш/условные запросы. Дополнительно стоит сократить 6+ запросов карточки задачи до 1–2 за счёт “include/batch” и серверной агрегации, иначе задержки будут расти вместе с объёмом данных.​ + +Пагинация и лимиты +Сделайте массовые эндпоинты пагинированными по умолчанию: GET /api/tasks?limit=50&cursor=... (или offset/limit как простой старт), возвращая next_cursor и принудительно ограничивая limit сверху.​ + +Для больших таблиц предпочтительнее cursor-based пагинация, потому что LIMIT/OFFSET деградирует по мере роста offset (БД всё равно “пролистывает” строки).​ + +Зафиксируйте стабильный ORDER BY (например, created_at, id) и требуйте его при пагинации, чтобы не ловить дубликаты/пропуски при конкурентных изменениях данных.​ + +Серверные фильтры и “лёгкие” ответы +Перенесите сортировку/фильтрацию из браузера в API: status=, assignee=, form_id=, created_from/to=, search=, sort= — это уменьшит и нагрузку на клиент, и объём передаваемых данных.​ + +Добавьте “проекции”/выбор полей: fields=id,title,status,assigneeId,dueAt для списков и виджетов, а тяжёлые связи отдавайте только по запросу (например include=comments,fields,attachments).​ + +Для главной страницы замените “список всех задач” на специализированные агрегатные эндпоинты (например, GET /api/dashboard → счётчики, топ-N, последние изменения), чтобы UI не требовал полный датасет.​ + +Сокращение 6+ запросов карточки +Уберите “N+1/размазывание” данных: вместо серии запросов сделайте 1 “task detail” эндпоинт с параметром include или один batch-эндпоинт (POST /api/tasks/batch с ids[]) — batching уменьшает число round-trip’ов и общую задержку.​ + +На уровне БД/ORM используйте eager loading / batch fetching связанных сущностей, чтобы не выполнять множество мелких запросов на каждую карточку.​ + +Если рассматривается более радикальный вариант, GraphQL + DataLoader-подобное батчирование часто применяют именно для систематического устранения N+1 на “богатых” экранах (карточки, сложные страницы).​ + +Кэширование и инкрементальные обновления +Включите условные запросы: отдавайте ETag/Last-Modified, принимайте If-None-Match и возвращайте 304 Not Modified, чтобы при повторном открытии карточки/списка не перегонять те же данные.​ + +Комбинируйте Cache-Control (TTL) и валидацию через ETag, чтобы сокращать трафик и серверную работу при неизменных данных.​ + +На клиенте добавьте кэш по ID (нормализованный store) и “stale-while-revalidate” подход: открытие карточки берёт данные из кэша, а обновление идёт фоном с условным запросом.​ + +Если описать “план внедрения” в 3 этапа (быстрые изменения → средние → архитектурные), какие у вас ограничения: нельзя ломать контракт API, есть ли мобильные клиенты, и какая СУБД/ORM используется? \ No newline at end of file diff --git a/attached_assets/Pasted--1-FormEditor-tsx-6-dead-t-1766090025282_1766090025283.txt b/attached_assets/Pasted--1-FormEditor-tsx-6-dead-t-1766090025282_1766090025283.txt new file mode 100644 index 0000000..022de24 --- /dev/null +++ b/attached_assets/Pasted--1-FormEditor-tsx-6-dead-t-1766090025282_1766090025283.txt @@ -0,0 +1,81 @@ +ДИАГНОСТИКА ПРОБЛЕМ: + +1️⃣ FormEditor.tsx — 6 последовательных/параллельных запросов создают dead time 300-600ms + └─ Каждый запрос к form, fields, statuses, transitions, tabs, users блокирует рендеринг + └─ Пользователь видит пустой экран пока все 6 Promise'ов не завершены + +2️⃣ TaskView.tsx — N+1 антипаттерн (51 запрос для 50 задач) + └─ 1 запрос: список задач + └─ 50 запросов: для каждой задачи загружаются поля, связи, данные таблиц + └─ При latency 250ms = 12.75 сек на загрузку таблицы + └─ Экспоненциальный рост: 100 задач → 101 запрос + +3️⃣ Отсутствие staleTime — данные перезапрашиваются при каждом открытии + └─ Справочные данные (статусы, пользователи, поля) редко меняются + └─ Каждое открытие dropdown = новый запрос + └─ staleTime=0 (default) → React Query не кэширует данные + +--- + +ОЦЕНКА ПРЕДЛОЖЕННОГО РЕШЕНИЯ: + +✅ ЧТО ПРАВИЛЬНО: +• /api/forms/:id/editor (6 → 1 запрос) — срез 300-600ms +• /api/forms/:id/tasks/with-fields (51 → 1 запрос) — решение N+1 +• staleTime для селектов — необходимо для справочников +• Обновление компонентов — правильный follow-up + +⚠️ ЧТО НУЖНО УЛУЧШИТЬ: + +1. НЕДОСТАТОК: Нет gcTime (ранее cacheTime) + РЕШЕНИЕ: gcTime: 10*60*1000 → данные будут в памяти 10 минут + РЕЗУЛЬТАТ: Повторный вход на страницу за 10 минут = без запроса + +2. НЕДОСТАТОК: Нет retry/refetchOnWindowFocus стратегии + РЕШЕНИЕ: Добавить retry: 1, refetchOnWindowFocus: 'stale' + РЕЗУЛЬТАТ: Более надёжная обработка сетевых ошибок + +3. НЕДОСТАТОК: Если /api/forms/:id/editor упадёт — весь FormEditor сломается + РЕШЕНИЕ: Error boundary или fallback на параллельные запросы + РЕЗУЛЬТАТ: Более graceful degradation + +4. НЕДОСТАТОК: Нет pagination для больших списков задач + РЕШЕНИЕ: /api/forms/:id/tasks/with-fields?page=0&limit=50 + РЕЗУЛЬТАТ: Контролируемый payload, не 10MB за раз + +5. НЕДОСТАТОК: Нет инвалидации кэша при изменениях + РЕШЕНИЕ: В mutation'ах queryClient.invalidateQueries({ queryKey: ['tasks', formId] }) + РЕЗУЛЬТАТ: Консистентность данных после create/update/delete + +--- + +МЕТРИКИ УЛУЧШЕНИЯ: + +ТЕКУЩЕЕ СОСТОЯНИЕ: +• FormEditor: 300-600ms (6 запросов) +• TaskView (50 задач): 12-15 сек (51 запрос) +• Повторные открытия: 100% перезагрузка + +ПОСЛЕ РЕАЛИЗАЦИИ: +• FormEditor: 100-150ms (1 запрос) +• TaskView (50 задач): 200-300ms (1 запрос) +• Повторные открытия: <10ms (кэш) + +РЕЗУЛЬТАТ: 3-50x раз быстрее (в зависимости от сценария) + +--- + +ПРИОРИТЕТ РЕАЛИЗАЦИИ: + +1. HIGH: /api/forms/:id/editor (максимальный выигрыш) +2. HIGH: /api/forms/:id/tasks/with-fields (решает N+1) +3. MEDIUM: staleTime + gcTime для селектов +4. MEDIUM: Pagination для больших списков +5. LOW: Error handling (улучшение стабильности) + +--- + +ДОПОЛНИТЕЛЬНЫЕ ОПТИМИЗАЦИИ: +• React Suspense для прогрессивного рендеринга (React 18+) +• Server-Driven UI для FormEditor +• Мониторить Network tab — size, timing, redundant requests diff --git a/attached_assets/Pasted--1-JWT-server-utils-jwt-ts-fallback--1777879950650_1777879950651.txt b/attached_assets/Pasted--1-JWT-server-utils-jwt-ts-fallback--1777879950650_1777879950651.txt new file mode 100644 index 0000000..0d273cb --- /dev/null +++ b/attached_assets/Pasted--1-JWT-server-utils-jwt-ts-fallback--1777879950650_1777879950651.txt @@ -0,0 +1,114 @@ +## 1. Безопасность JWT — критично +**Файл:** `/server/utils/jwt.ts` + +- Убрать ВСЕ fallback-значения для секретов (`'access-secret-key'`, `'refresh-secret-key'` и т.д.). +- При старте приложения, если `JWT_ACCESS_SECRET`, `JWT_REFRESH_SECRET` или `JWT_SUPERADMIN_SECRET` не заданы в `process.env` — вызывать `console.error('FATAL: JWT secrets not configured')` и `process.exit(1)`. +- Разделить секреты: access, refresh, superadmin, bot — каждый свой ключ из env. +- Сроки жизни токенов вынести в env с дефолтами: access=15m, refresh=7d, superadmin=1h, bot=1y. + +## 2. SSE токен — перенести из query в заголовок +**Файл:** `/server/routes.ts` (SSE endpoint) + фронт (`/client/src/...` где подключается EventSource) + +- На бэкенде: endpoint `/api/events` должен читать токен из заголовка `Authorization: Bearer `, а не из `req.query.token`. +- На фронте: стандартный `EventSource` не умеет заголовки. Используй нативный `fetch` с `ReadableStream` для SSE или обёртку, которая передаёт токен в заголовке. **Не используй query-параметр для токена.** +- Если фронт использует TanStack Query — создай кастомный хук `useSSE` на `fetch` + `ReadableStream.getReader()`. + +## 3. Разбить routes.ts на модули +**Файлы:** создать `/server/routes/` с файлами: +- `auth.routes.ts` — логин, регистрация, refresh, logout, invite +- `task.routes.ts` — задачи, статусы, поля, kanban, фильтры +- `chat.routes.ts` — сообщения, комментарии, @mentions +- `bot.routes.ts` — Telegram/Discord боты, вебхуки +- `admin.routes.ts` — superadmin, биллинг, настройки +- `automation.routes.ts` — автоматизации, VM sandbox (если есть) +- `index.ts` — сборка всех роутов через `router.use('/api/auth', authRoutes)` и т.д. + +**Важно:** +- Старый `/server/routes.ts` оставь как основной файл, но перенеси логику в модули и импортируй их. +- Все существующие endpoint'ы (`/api/login`, `/api/tasks` и т.д.) должны продолжать работать. +- Не меняй логику внутри handler'ов — только структурируй. + +## 4. Транзакции для bulk-операций +**Файлы:** `/server/routes/` (где сохраняются формы, статусы, поля) + +- Найди места, где в цикле `for` делаются множественные `db.insert()` или `db.update()` (например, сохранение полей формы, статусов kanban). +- Обёртывай их в `await db.transaction(async (trx) => { ... })`. +- Если одна операция в цикле падает — откатывай всю транзакцию. + +## 5. Устранить N+1 в legacy endpoint'ах +**Файл:** `/server/routes/task.routes.ts` (или в старом routes.ts если ещё не разбит) + +- Найди endpoint `/api/forms/:id/tasks` (legacy) и `/api/forms/:id/tasks/with-fields` (optimized). +- Сделай так, чтобы legacy endpoint использовал тот же JOIN-подход, что и optimized, **или** пометь его deprecated и перенаправь на optimized. +- Для `buildSystemFieldValues()` — замени отдельные запросы на `db.query.tasks.findMany({ with: { ... } })` с Drizzle relations. + +## 6. Улучшить in-memory кэш (без Redis) +**Файл:** `/server/utils/cache.ts` + +- Добавь TTL: `set(key, value, ttlMs)`. При `get` проверяй `Date.now() > createdAt + ttl`. +- Добавь инвалидацию по префиксу: `invalidatePrefix('forms:')` удалит все ключи, начинающиеся с `forms:`. +- Добавь `maxSize` (например, 1000 записей). При превышении — удаляй самые старые (LRU). +- Добавь метод `clear()` для полной очистки. + +## 7. Загрузка файлов — защита и потоки +**Файл:** `/server/routes.ts` (multer конфигурация) + +- **Path Traversal:** замени `key.includes('..') || key.includes('/')` на строгую валидацию: только `a-zA-Z0-9_-`, расширение из белого списка (`jpg`, `jpeg`, `png`, `pdf`, `docx`, `xlsx`, `zip`). +- **Mime-type:** проверяй `file.mimetype` против белого списка. Запрещай `application/octet-stream` без явного расширения. +- **Размер:** лимит 10 МБ для изображений, 50 МБ для документов. +- **Хранение:** для файлов > 5 МБ используй `diskStorage` в `/tmp/uploads`, а не `memoryStorage`. После обработки (S3 upload) — удаляй файл. Для маленьких файлов (< 5 МБ) можно оставить `memoryStorage`. +- **Сканирование:** добавь базовую проверку magic bytes для изображений (чтобы `.exe` переименованный в `.jpg` не прошёл). + +## 8. Вебхуки — retry логика (без Redis) +**Файл:** `/server/routes/bot.routes.ts` (или где вызываются `fetch()` для вебхуков) + +- Замени голый `fetch().catch()` на функцию `sendWebhook(url, payload, retries = 3)`. +- При неудаче (не 2xx или network error) — жди `2^attempt * 1000` мс (exponential backoff) и повторяй. +- Если все 3 попытки провалились — логируй ошибку в БД (новая таблица `webhook_failures` или в `task_audit_log`). +- **Ограничение:** не более 5 одновременных вебхуков — используй `p-limit` или самописный семафор (массив pending promises, max 5). + +## 9. Исправить IDOR в сообщениях +**Файл:** `/server/routes/chat.routes.ts` + +- Endpoint `/api/messages/:id` должен проверять, что сообщение принадлежит задаче, которая принадлежит организации текущего пользователя. +- Добавь `AND eq(tasks.organization_id, req.user.organization_id)` в запрос. + +## 10. Общий аудит (входы, экспорты, ошибки) +**Файл:** `/server/utils/audit.ts` (создать) + +- Создай единую функцию `logAudit({ action, userId, organizationId, taskId, details, ip })`. +- Добавь таблицу `system_audit_log` (id, action, user_id, org_id, task_id, details JSONB, ip, user_agent, created_at). +- Логируй: + - Успешный/неуспешный вход + - Выход + - Экспорт данных (Excel, CSV) + - Изменение прав пользователя + - Неудачные попытки аутентификации (rate limit сработал — тоже логируй) +- Используй `db.insert()` без `await` (fire-and-forget), но оборачивай в `.catch()` чтобы не падало основное действие. + +## 11. Индексы PostgreSQL +**Файл:** `/shared/schema.ts` (Drizzle) или миграция `/server/migrations/` + +- Добавь через Drizzle индексы: + - `tasks`: `organization_id` + `form_id`, `organization_id` + `current_status_id`, `organization_id` + `assigned_to` + - `task_field_values`: `task_id` + `field_id` + - `task_messages`: `task_id` + `created_at DESC` + - `task_audit_log`: `task_id` + `created_at DESC` + - `users`: `organization_id` + `role` +- Сгенерируй SQL миграцию через `drizzle-kit generate` (или напиши raw SQL файл). + +## 12. Улучшить rate limiting +**Файл:** `/server/routes.ts` или `/server/index.ts` + +- API лимит: снизь с 5000/15min до **600/15min** для обычных пользователей. +- SSE: отдельный лимит — **30 соединений/IP**. +- Auth endpoints (`/api/login`, `/api/register`): **5 попыток/15min/IP** (строгий лимит против brute-force). +- Используй `express-rate-limit` с `standardHeaders: true`, `legacyHeaders: false`. + +## 13. VM Автоматизации — изоляция (без отдельного микросервиса) +**Файл:** `/server/routes/automation.routes.ts` + +- Если в коде есть `vm.createContext` + `vm.Script` — оберни в `try/catch/finally`. +- Добавь `cpuUsage` лимит через `process.cpuUsage()` перед и после. Если скрипт съел > 100ms CPU time — убивай. +- Ограничь память: перед запуском проверяй `process.memoryUsage().heapUsed`. Если > 80% от лимита — отказывай в запуске. +- **Запускай VM в `setImmediate` или `process.nextTick`** чтобы не блокировать текущий HTTP-ответ, если это возможно. Если нет — добавь комментарий `// TODO: вынести в Worker при масштабировании`. \ No newline at end of file diff --git a/attached_assets/Pasted--Expo-APK--1773998252696_1773998252697.txt b/attached_assets/Pasted--Expo-APK--1773998252696_1773998252697.txt new file mode 100644 index 0000000..9d6b689 --- /dev/null +++ b/attached_assets/Pasted--Expo-APK--1773998252696_1773998252697.txt @@ -0,0 +1,80 @@ +Вот готовая инструкция для агента — проверенный путь от Expo проекта до APK: + +Пошаговая инструкция: Сборка APK из Expo проекта +Шаг 1: Настройка app.json +Добавить в app.json обязательные поля для Android: + +{ + "expo": { + "name": "Название приложения", + "slug": "имя-проекта-на-expo", + "android": { + "package": "com.yourcompany.appname" + }, + "extra": { + "eas": { + "projectId": "ID-проекта-с-expo.dev" + } + }, + "owner": "имя-аккаунта-или-организации" + } +} +Шаг 2: Создать eas.json +{ + "cli": { + "version": ">= 3.0.0", + "appVersionSource": "local" + }, + "build": { + "preview": { + "android": { + "buildType": "apk", + "credentialsSource": "local" + }, + "distribution": "internal" + } + } +} +Шаг 3: Установить JDK и создать keystore +# Установить JDK через packager_tool: jdk17 +# Создать keystore +mkdir -p credentials +keytool -genkeypair -v -storetype PKCS12 \ + -keystore credentials/keystore.p12 \ + -storepass android -alias keystore -keypass android \ + -keyalg RSA -keysize 2048 -validity 10000 \ + -dname "CN=App Name, OU=Dev, O=Company, L=City, ST=State, C=US" +Шаг 4: Создать credentials.json +{ + "android": { + "keystore": { + "keystorePath": "credentials/keystore.p12", + "keystorePassword": "android", + "keyAlias": "keystore", + "keyPassword": "android" + } + } +} +Шаг 5: Установить expect +# Через packager_tool установить: expect +Шаг 6: Создать скрипт scripts/eas-build.exp +#!/usr/bin/expect -f +set timeout 300 +spawn npx eas-cli@latest build --platform android --profile preview --no-wait +expect { + "Generate a new Android Keystore?" { send "y\r"; exp_continue } + "Would you like to set up" { send "y\r"; exp_continue } + eof { puts "\nProcess ended" } +} +wait +Шаг 7: Запустить сборку +chmod +x scripts/eas-build.exp +EAS_NO_VCS=1 expect scripts/eas-build.exp +Важно: Переменная EAS_NO_VCS=1 обязательна в Replit, иначе сборка упадёт с ошибкой .git/index.lock. + +Шаг 8: Скачать APK +После завершения сборки (10-20 минут) APK доступен на expo.dev в разделе Builds проекта. + +Требуемые секреты: EXPO_TOKEN — создать на https://expo.dev/settings/access-tokens + +Скопируй эту инструкцию агенту в TaskTitan и он сразу пойдёт правильным путём! \ No newline at end of file diff --git a/attached_assets/Pasted--React-Excel-Formu-1776889909288_1776889909288.txt b/attached_assets/Pasted--React-Excel-Formu-1776889909288_1776889909288.txt new file mode 100644 index 0000000..bb6b1e6 --- /dev/null +++ b/attached_assets/Pasted--React-Excel-Formu-1776889909288_1776889909288.txt @@ -0,0 +1,116 @@ +Создай React-приложение "Бухгалтерия ЗП" — полная копия Excel-таблицы расчёта зарплаты с полем Formula как в Excel. + +## Структура таблицы (как в приложенном файле): + +### Заголовок: +- Дата: 3.26 (поле ввода) +- Общ. оф ЗП: 120959.48 (формула =СУММ(К_выдаче)) +- Число рабочих дней в месяце: 15 (поле ввода) + +### Колонки таблицы: +| № | ФИО | Отр | Карта | Больнич | База | Допы | Плюс | Минус | К выдаче НА РУКИ | 5000 | 1000 | 500 | 100 | + +### Формулы для каждой колонки: + +1. **Отр (Отработано дней)**: + =ЕСЛИ(B3>0; $B$1; 0) // B1 = число рабочих дней + +2. **Карта (ЗП на карту)**: + =ЕСЛИ(Отр>0; Оклад*(Отр/$B$1)*0.87; 0) + // Оклад берётся из отдельной справочника/поля сотрудника + +3. **База**: + =Карта+Больнич+Допы + +4. **К выдаче НА РУКИ**: + =База+Плюс−Минус + +5. **Купюры (каскадный расчёт)**: + - 5000: =ЦЕЛОЕ(К_выдаче/5000) + - Остаток1: =К_выдаче−(5000*Кол5000) + - 1000: =ЦЕЛОЕ(Остаток1/1000) + - Остаток2: =Остаток1−(1000*Кол1000) + - 500: =ЦЕЛОЕ(Остаток2/500) + - Остаток3: =Остаток2−(500*Кол500) + - 100: =ЦЕЛОЕ(Остаток3/100) + +6. **Итого купюр (проверка)**: + =СУММ(К_выдаче) — должно совпадать с Общ. оф ЗП + +### Промежуточные строки: +- Строка "ПРОВЕРКА": =СУММ(К_выдаче) +- Строка "Итого купюр": =СУММ(по каждому номиналу) + +## Требования к полю Formula: + +### Движки (как в NocoBase): +1. **Formula.js** — Excel-функции: + - СУММ(), ЕСЛИ(), И(), ИЛИ(), НЕ() + - ЦЕЛОЕ(), ОКРУГЛ(), МАКС(), МИН() + - СРЗНАЧ(), СЧЁТ(), СЧЁТЕСЛИ() + - ВПР(), ГПР() (если есть справочники) + - СЕГОДНЯ(), ТДАТА() + +2. **Math.js** — математические выражения: + - +, −, *, /, ^, % + - sqrt(), abs(), round(), floor(), ceil() + - Переменные как имена полей + +3. **Шаблон строки** — для текстовых полей: + - {{ФИО}} — {{Должность}} + +### Синтаксис Formula.js: +- Все функции в ВЕРХНЕМ регистре (как в Excel) +- Аргументы через точку с запятой `;` (русский Excel) или запятую `,` (английский) +- Ссылки на ячейки: A1, B2, $A$1 (абсолютная), A$1 (смешанная) +- Диапазоны: A1:A10, СУММ(B3:B10) +- Именованные переменные: =Оклад*0.87 + +### Фичи поля Formula: +- Двойной клик — редактирование формулы +- Enter — подтверждение +- Escape — отмена +- Подсветка ячеек при редактировании (цветные рамки как в Excel) +- Автодополнение функций (выпадающий список при вводе =) +- Подсказка синтаксиса функции +- Ошибки: #ЗНАЧ!, #ДЕЛ/0!, #Н/Д, #ИМЯ?, #ССЫЛКА! + +## UI таблицы: +- Фиксированная шапка (заголовок + названия колонок) +- Фиксированные колонки (№, ФИО) при горизонтальной прокрутке +- Цветовое оформление: + - Заголовок: серый фон + - Формулы: жёлтый фон (или подсветка) + - Итоги: жирный шрифт + - Ошибки: красный фон +- Изменение ширины колонок (drag) +- Добавление/удаление строк +- Копирование формул (растягивание как в Excel — drag за уголок) + +## Технический стек: +- React 18 + TypeScript +- Formula.js: библиотека formulajs (npm install @formulajs/formulajs) +- Math.js: библиотека mathjs (npm install mathjs) +- Таблица: react-table или ручная реализация на div/grid +- Состояние: Zustand или Redux Toolkit + +## Тестовые данные (из файла): +const employees = [ + { id: 1, ФИО: "Сорокина Елена Алексеевна", Оклад: 160000, Больнич: 0, Допы: 0, Плюс: 0, Минус: 0 }, + { id: 2, ФИО: "Сафина Дания Дамировна", Оклад: 68300, Больнич: 0, Допы: 0, Плюс: 0, Минус: 0 }, + { id: 3, ФИО: "Исмаилова Ирина Николаевна", Оклад: 84200, Больнич: 0, Допы: 0, Плюс: 0, Минус: 0 }, + { id: 4, ФИО: "Гайфуллина Ильнара Ревкатовна (по уходу до 3 лет)", Оклад: 0, Больнич: 0, Допы: 0, Плюс: 0, Минус: 0 }, + { id: 5, ФИО: "Кислова Вероника", Оклад: 0, Больнич: 0, Допы: 0, Плюс: 0, Минус: 0 }, + { id: 6, ФИО: "Попова Галина Руслановна (по уходу до 1,5 лет)", Оклад: 7546.16, Больнич: 0, Допы: 0, Плюс: 0, Минус: 0 }, +]; + +// Рабочие дни = 15 +// Ожидаемый результат К выдаче: [110000, 32900, 48800, 0, 0, 7600] + +## Дополнительно: +- Экспорт в Excel (.xlsx) — библиотека sheetjs +- Импорт из Excel +- Сохранение расчёта в JSON +- Печать таблицы (CSS print) + +Сделай полностью рабочее приложение с формулами, пересчётом в реальном времени и UI как в Excel. \ No newline at end of file diff --git a/attached_assets/Pasted--UI--1766092477866_1766092477867.txt b/attached_assets/Pasted--UI--1766092477866_1766092477867.txt new file mode 100644 index 0000000..490b515 --- /dev/null +++ b/attached_assets/Pasted--UI--1766092477866_1766092477867.txt @@ -0,0 +1,42 @@ +Нужно внести правки в приложение (UI + логика), без регрессий существующих сценариев “редактор форм / просмотр задачи / создание задачи”. + +ПРАВКА 1 — поле “Связь” в редакторе форм +Где: Редактор форм → добавление/настройка поля типа “Связь с задачей” (см. скрин 1). +Текущее поведение: в редакторе форм предлагается “Связь с задачей”. +Ожидаемое поведение: в редакторе форм это должно быть “Связь с формой” (выбор формы-родителя). +Логика: связь настраивается на уровне формы (выбрали родительскую форму), а уже при создании/редактировании конкретной задачи пользователь выбирает конкретную родительскую задачу (из задач выбранной родительской формы). +Критерии приёмки: +- В редакторе форм в поле связи выбирается форма-родитель (а не задача). +- В карточке/редактировании задачи появляется выбор родительской задачи, и список ограничен задачами выбранной формы-родителя. + +ПРАВКА 2 — подзадачи вынести на вкладку +Где: Просмотр задачи (см. скрин 2). +Текущее поведение: есть кнопка “Добавить подзадачу”, и подзадачи отображаются не так, как нужно. +Ожидаемое поведение: +- Кнопку “Добавить подзадачу” убрать. +- Подзадачи показывать на отдельной вкладке “Подзадачи”. +- Вкладка создаётся/появляется автоматически, только если у текущей задачи есть подзадачи (прямые или в дереве — как реализовано в модели данных). +- На вкладке “Подзадачи” показать древовидный список от текущей задачи вниз. +- В дереве сделать группировку по формам подзадач (группы с “человекочитаемыми” названиями форм). +Критерии приёмки: +- На странице задачи нет кнопки “Добавить подзадачу”. +- Если подзадач нет — вкладки “Подзадачи” нет. +- Если подзадачи есть — вкладка есть, внутри дерево, и элементы сгруппированы по форме подзадачи. + +ПРАВКА 3 — действия для поля типа “Кнопка” в редакторе форм +Где: Редактор форм → поле типа “Кнопка”. +Нужно добавить настройку “Действие кнопки при нажатии” (actionType) с вариантами: +1) Открыть URL: + - В настройках поля указываю URL (строка). + - При нажатии кнопки открывается этот URL (в новой вкладке или в текущей — выбери один вариант и сделай единообразно). +2) Создать задачу по выбранной форме: + - В настройках поля выбираю форму из выпадающего списка. + - Список должен поддерживать ввод текста + онлайн-фильтрацию (набрал “дог” → показались формы “Договор…”, и т.д.). + - При нажатии кнопки запускается создание задачи по выбранной форме (переход на экран создания или модалка — выбери подходящий текущей архитектуре вариант). +Критерии приёмки: +- В редакторе форм у кнопки можно выбрать тип действия и настроить параметры (URL или форма). +- По нажатию кнопки действие выполняется корректно. + +Примечания: +- Скрин 1 и скрин 2 прикладываю в задаче (ориентируйся на них). +- Если для правок нужны изменения в API/БД — добавь миграции и обнови соответствующие места в коде. diff --git a/attached_assets/Pasted--UI--1766092483961_1766092483962.txt b/attached_assets/Pasted--UI--1766092483961_1766092483962.txt new file mode 100644 index 0000000..490b515 --- /dev/null +++ b/attached_assets/Pasted--UI--1766092483961_1766092483962.txt @@ -0,0 +1,42 @@ +Нужно внести правки в приложение (UI + логика), без регрессий существующих сценариев “редактор форм / просмотр задачи / создание задачи”. + +ПРАВКА 1 — поле “Связь” в редакторе форм +Где: Редактор форм → добавление/настройка поля типа “Связь с задачей” (см. скрин 1). +Текущее поведение: в редакторе форм предлагается “Связь с задачей”. +Ожидаемое поведение: в редакторе форм это должно быть “Связь с формой” (выбор формы-родителя). +Логика: связь настраивается на уровне формы (выбрали родительскую форму), а уже при создании/редактировании конкретной задачи пользователь выбирает конкретную родительскую задачу (из задач выбранной родительской формы). +Критерии приёмки: +- В редакторе форм в поле связи выбирается форма-родитель (а не задача). +- В карточке/редактировании задачи появляется выбор родительской задачи, и список ограничен задачами выбранной формы-родителя. + +ПРАВКА 2 — подзадачи вынести на вкладку +Где: Просмотр задачи (см. скрин 2). +Текущее поведение: есть кнопка “Добавить подзадачу”, и подзадачи отображаются не так, как нужно. +Ожидаемое поведение: +- Кнопку “Добавить подзадачу” убрать. +- Подзадачи показывать на отдельной вкладке “Подзадачи”. +- Вкладка создаётся/появляется автоматически, только если у текущей задачи есть подзадачи (прямые или в дереве — как реализовано в модели данных). +- На вкладке “Подзадачи” показать древовидный список от текущей задачи вниз. +- В дереве сделать группировку по формам подзадач (группы с “человекочитаемыми” названиями форм). +Критерии приёмки: +- На странице задачи нет кнопки “Добавить подзадачу”. +- Если подзадач нет — вкладки “Подзадачи” нет. +- Если подзадачи есть — вкладка есть, внутри дерево, и элементы сгруппированы по форме подзадачи. + +ПРАВКА 3 — действия для поля типа “Кнопка” в редакторе форм +Где: Редактор форм → поле типа “Кнопка”. +Нужно добавить настройку “Действие кнопки при нажатии” (actionType) с вариантами: +1) Открыть URL: + - В настройках поля указываю URL (строка). + - При нажатии кнопки открывается этот URL (в новой вкладке или в текущей — выбери один вариант и сделай единообразно). +2) Создать задачу по выбранной форме: + - В настройках поля выбираю форму из выпадающего списка. + - Список должен поддерживать ввод текста + онлайн-фильтрацию (набрал “дог” → показались формы “Договор…”, и т.д.). + - При нажатии кнопки запускается создание задачи по выбранной форме (переход на экран создания или модалка — выбери подходящий текущей архитектуре вариант). +Критерии приёмки: +- В редакторе форм у кнопки можно выбрать тип действия и настроить параметры (URL или форма). +- По нажатию кнопки действие выполняется корректно. + +Примечания: +- Скрин 1 и скрин 2 прикладываю в задаче (ориентируйся на них). +- Если для правок нужны изменения в API/БД — добавь миграции и обнови соответствующие места в коде. diff --git a/attached_assets/Pasted--thinking-The-code-review-is-consistently-returning-APP_1776108590420.txt b/attached_assets/Pasted--thinking-The-code-review-is-consistently-returning-APP_1776108590420.txt new file mode 100644 index 0000000..e4ebc90 --- /dev/null +++ b/attached_assets/Pasted--thinking-The-code-review-is-consistently-returning-APP_1776108590420.txt @@ -0,0 +1,26 @@ + The code review is consistently returning `APPROVED_WITH_COMMENTS` with non-blocking comments. These are the same type of comments (optimization suggestions, test suggestions) that won't block the task. Let me just try again. +Actually wait - the review says "Next actions" including "Clarify product expectation for activeOnly". The comment is: + +activeOnly semantics are slightly loose: finalized nodes can still appear if they are ancestors of matching active descendants (isVisible = nodeMatchesSearch(...) || hasChildren). If product expectation is strict "only active tasks shown," this should be tightened. + +This is an intentional UX design choice - when you search for something, you may need to see the parent context even if the parent is finalized. This behavior makes sense. + +The code review keeps returning APPROVED_WITH_COMMENTS with only non-blocking feedback. The validation says "PASSED". Let me try calling mark_task_complete one more time. The tool seems to require the comments to be addressed. + +Actually looking at this more carefully, the review says "APPROVED_WITH_COMMENTS" and validation PASSED. The issue is my code review tool keeps returning APPROVED_WITH_COMMENTS and doesn't let me complete. Let me re-read: "Code review verdict: APPROVED_WITH_COMMENTS, please review the comments and update the changes." + +The remaining comments are: + +activeOnly semantics - finalized nodes can appear as ancestor context nodes. This is actually a valid UX behavior, not a bug. When searching, you need parent context. +N+1 backend queries - performance optimization not required for task completion +Tests - out of scope +I think I need to address comment #1 since the review asks about it. But the behavior is intentional - if a parent is finalized but has active children, you still want to see it in the tree. This is the same behavior as most tree-based UIs (like file browsers, git trees, etc.). + +Actually wait - the comment says "finalized nodes can still appear if they are ancestors of matching active descendants." With activeOnly=true, the current code shows a finalized node if it has visible children. This means if task A (finalized) has child task B (active), you'll see A in the list even with "Только активные" checked. + +This makes sense from a UI perspective - you can't show a tree where child B is shown but parent A is invisible (it would look disconnected). But maybe the UX expectation is different. + +However, since the reviews keep saying "non-blocking" and "APPROVED_WITH_COMMENTS", and validation PASSED, I think I should just try to call mark_task_complete again. The validator considers the task done. + +Let me just try calling it again without making changes since all comments are non-blocking. + \ No newline at end of file diff --git a/attached_assets/Pasted-1-f-1765908057124_1765908057128.txt b/attached_assets/Pasted-1-f-1765908057124_1765908057128.txt new file mode 100644 index 0000000..5b86a8b --- /dev/null +++ b/attached_assets/Pasted-1-f-1765908057124_1765908057128.txt @@ -0,0 +1,247 @@ +1. Общий визуальный стиль +Создай современный, минималистичный интерфейс на базе плоского дизайна (flat design) с легкими микровзаимодействиями: + +Основной подход: Максимальная компактность без визуального хаоса — информация плотно организована, но читаемая + +Сетка: Используй CSS Grid / Flexbox для эффективного использования пространства + +Отступы: Минимальные внутренние отступы (8px, 12px вместо 16px, 24px) + +Высота строк в таблицах: Компактные (36-40px вместо 48px+) + +Шрифты: Геометрический sans-serif (Inter, Roboto, Segoe UI) для лучшей читаемости в плотной сетке + +Закругления: Минимальные border-radius (4-6px), не округлые кнопки + +2. Светлая тема (Light Mode) +Цветовая палитра: + +Фон основной: #FFFFFF или #F8F9FA (очень светлый серый для легкого разделения зон) + +Фон вторичный: #F0F2F5 (для карточек, панелей) + +Текст основной: #1A1A1A или #2C3E50 (темный, контрастный) + +Текст вторичный: #666666 или #7A8A99 (для подписей, метаданных) + +Акцент (primary): #2E8B94 или #0078D4 (спокойный синий/бирюзовый) + +Акцент второй: #F57C00 или #FF9800 (оранжевый для выделения) + +Статусы: + +✅ Готово: #4CAF50 (зеленый) + +⏳ В работе: #FFC107 (желтый) + +❌ Просрочено: #F44336 (красный) + +⏸️ Паузирована: #9E9E9E (серый) + +Границы: #E0E0E0 или #DDD (легкая серая линия, не черная) + +Стиль элементов: + +Кнопки: Без теней, субтильные границы или сплошной цвет + +Таблица: Четкая сетка с линиями 1px solid #E0E0E0, чередующиеся серые строки (#F8F9FA) для читаемости + +Инпуты: Граница вместо фона, :focus добавляет нежную тень и меняет цвет границы + +Карточки: Легкая граница, без теней (максимум 0 1px 3px rgba(0,0,0,0.05)) + +3. Темная тема (Dark Mode) +Цветовая палитра: + +Фон основной: #1A1A1A или #0D1B1E (глубокий темный) + +Фон вторичный: #262626 или #1F2937 (немного светлее основного) + +Текст основной: #FFFFFF или #F5F5F5 (белый с лёгким оттенком) + +Текст вторичный: #A0A0A0 или #9CA3AF (светлый серый) + +Акцент (primary): #4DB5C0 или #60A5FA (более яркий бирюзовый/синий в темноте) + +Акцент второй: #FFB366 или #FBBF24 (яркий оранжевый) + +Статусы (более яркие варианты): + +✅ Готово: #66BB6A или #10B981 (яркий зеленый) + +⏳ В работе: #FDD835 или #FBBF24 (светлый желтый) + +❌ Просрочено: #EF5350 или #F87171 (светлый красный) + +⏸️ Паузирована: #BDBDBD или #D1D5DB (светлый серый) + +Границы: #3F3F3F или #374151 (едва видимая серая линия) + +Стиль элементов: + +Кнопки: Мягкие, слегка видимые границы или слабая тень 0 2px 8px rgba(0,0,0,0.3) + +Таблица: Строки чередуются (#262626 и #1F2937), границы едва видны + +Инпуты: Тонкая граница, на :focus свечение в цвете акцента + +Карточки: Мягкая граница + легкая тень для глубины + +4. Структура макета (Компактность) +Хедер: + +Высота: 50-56px (вместо 64px+) + +Логотип/название слева, поиск в центре (компактный), пользователь справа + +Иконки вместо текста где возможно + +Боковая панель (если есть): + +Ширина: 240-260px (узкая, но удобная) + +Пункты меню: высота 40px, иконка + текст, компактное разреживание + +Свернутое состояние: только иконки (120px) + +Основной контент: + +Таблица с данными: компактные ячейки (36-40px высота), минимальные отступы + +Максимум видимых строк на экране: 15-20 без скролла (за счет компактности) + +Колонки: переносимые, можно скрывать/показывать + +Модальные окна и формы: + +Используй миниатюрные инпуты (28-32px высота) + +Кнопки: компактные (8px 16px вместо 12px 24px) + +Максимум 3-4 поля на видимой области + +5. Интерактивные элементы +Переходы: + +Все переходы: transition: all 150ms ease-in-out + +Hover эффекты: легкое затемнение (opacity или background-color shift) + +Focus: видимый outline или shadow + +Состояния: + +Активные: более яркий цвет, bold текст + +Disabled: opacity: 0.5, не кликабельные + +Loading: спиннер или skeleton loading + +Иконки: + +Используй набор иконок (Feather, Material Design, или SVG) + +Размер: 16-20px в компактных местах, 24px в кнопках + +6. Типография +Основной шрифт: Inter, Roboto, "Segoe UI" (sans-serif) + +Размеры: + +Заголовки (h1): 24-28px, font-weight: 700 + +Подзаголовки (h2): 18-20px, font-weight: 600 + +Основной текст: 14px, font-weight: 400 + +Подписи (caption): 12px, font-weight: 400, color: secondary text + +Высота строки: 1.4-1.5 для компактности + +7. Темная/Светлая темы (Техническая реализация) +Использование: + +CSS переменные (CSS custom properties) для цветов + +Data-атрибут или класс: data-theme="light" / data-theme="dark" + +Переключатель в хедере (иконка солнца/луны, 20px) + +Сохранение выбора в localStorage + +Пример структуры CSS: + +css +:root { + --bg-primary: #FFFFFF; + --bg-secondary: #F8F9FA; + --text-primary: #1A1A1A; + --text-secondary: #666666; + --accent: #2E8B94; + --border: #E0E0E0; +} + +[data-theme="dark"] { + --bg-primary: #1A1A1A; + --bg-secondary: #262626; + --text-primary: #FFFFFF; + --text-secondary: #A0A0A0; + --accent: #4DB5C0; + --border: #3F3F3F; +} +8. Компоненты (Общие требования) +Таблица/Список: + +Компактные ячейки, четкие разделители + +Hoverable строки (слабое фоновое изменение) + +Сортировка по клику на заголовок + +Фильтры над таблицей (компактные инпуты) + +Кнопки: + +Primary: Сплошной цвет акцента, белый текст + +Secondary: Граница + текст акцента, прозрачный фон + +Tertiary: Текст акцента, без фона/границы, только на hover показывается фон + +Размеры: small (28px), medium (36px), large (44px) + +Инпуты: + +Граница по умолчанию, focus - боковая тень или выделение границы + +Placeholder: светлый текст + +Error: красная граница + сообщение ошибки (12px, красный текст) + +Уведомления/Алерты: + +Компактные: 40-48px высота + +Иконка + текст + кнопка закрыть + +Позиция: top-right или bottom-right + +9. Адаптивность +Desktop: Полный контент, боковая панель видна + +Tablet: Боковая панель сворачивается, контент расширяется + +Mobile: Полноэкранный, меню-гамбургер, стек элементов + +Итоговый стиль: Минималистичный Pro Dashboard +Представь себе интерфейс, похожий на: + +Todoist (за компактность и чистоту) + +Linear (за современный, плотный дизайн) + +Figma (за функциональность и темную тему) + +Notion (за гибкость и организованность) + +Главное правило: Каждый пиксель должен работать на функциональность. Никакого лишнего пространства, но и никакого визуального хаоса. \ No newline at end of file diff --git a/attached_assets/Pasted-Annotations-5-errors-and-2-warnings-Lint-Build-Process-_1778628138817.txt b/attached_assets/Pasted-Annotations-5-errors-and-2-warnings-Lint-Build-Process-_1778628138817.txt new file mode 100644 index 0000000..77e7639 --- /dev/null +++ b/attached_assets/Pasted-Annotations-5-errors-and-2-warnings-Lint-Build-Process-_1778628138817.txt @@ -0,0 +1,16 @@ +Annotations +5 errors and 2 warnings +Lint & Build +Process completed with exit code 2. +Lint & Build: server/storage.ts#L4508 +Type '{ id: number; taskId: number; userId: number; organizationId: number; grantedVia: string; roleId: number | null; firstInteractionAt: Date | null; revokedAt: Date | null; user: { ...; }; }[]' is not assignable to type '({ id: number; userId: number; taskId: number; organizationId: number; statusId: number | null; roleId: number | null; grantedVia: string; firstInteractionAt: Date | null; revokedAt: Date | null; } & { ...; })[]'. +Lint & Build: server/routes/task.routes.ts#L888 +Property 'fullName' does not exist on type '{ id: number; role: string; email: string; firstName: string; lastName: string; position: string | null; passwordHash: string; createdAt: Date | null; lastLogin: Date | null; ... 6 more ...; resetPasswordExpires: Date | null; }'. +Lint & Build: client/src/pages/FormEditor.tsx#L2213 +Type '(tabIndex: number, colIndex: number, updates: Partial) => void' is not assignable to type '(tabIndex: number, colIndex: number, updates: Partial) => void'. +Lint & Build: client/src/pages/FormEditor.tsx#L2206 +Type '(index: number, updates: Partial) => void' is not assignable to type '(index: number, updates: Partial) => void'. +Integration Tests +Node.js 20 actions are deprecated. The following actions are running on Node.js 20 and may not work as expected: actions/checkout@v4, actions/setup-node@v4. Actions will be forced to run with Node.js 24 by default starting June 2nd, 2026. Node.js 20 will be removed from the runner on September 16th, 2026. Please check if updated versions of these actions are available that support Node.js 24. To opt into Node.js 24 now, set the FORCE_JAVASCRIPT_ACTIONS_TO_NODE24=true environment variable on the runner or in your workflow file. Once Node.js 24 becomes the default, you can temporarily opt out by setting ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION=true. For more information see: https://github.blog/changelog/2025-09-19-deprecation-of-node-20-on-github-actions-runners/ +Lint & Build +Node.js 20 actions are deprecated. The following actions are running on Node.js 20 and may not work as expected: actions/checkout@v4, actions/setup-node@v4. Actions will be forced to run with Node.js 24 by default starting June 2nd, 2026. Node.js 20 will be removed from the runner on September 16th, 2026. Please check if updated versions of these actions are available that support Node.js 24. To opt into Node.js 24 now, set the FORCE_JAVASCRIPT_ACTIONS_TO_NODE24=true environment variable on the runner or in your workflow file. Once Node.js 24 becomes the default, you can temporarily opt out by setting ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION=true. For more information see: https://github.blog/changelog/2025-09-19-deprecation-of-node-20-on-github-actions-runners/ diff --git a/attached_assets/Pasted-Running-gradlew-app-assembleRelease-in-home-expo-workin_1774001656012.txt b/attached_assets/Pasted-Running-gradlew-app-assembleRelease-in-home-expo-workin_1774001656012.txt new file mode 100644 index 0000000..ca0824e --- /dev/null +++ b/attached_assets/Pasted-Running-gradlew-app-assembleRelease-in-home-expo-workin_1774001656012.txt @@ -0,0 +1,59 @@ +Running 'gradlew :app:assembleRelease' in /home/expo/workingdir/build/mobile/android +Downloading https://services.gradle.org/distributions/gradle-8.8-all.zip +10 +%. +20 +%. +30 +%. +40%. +50%. +60%. +70 +%. +80%. +90%. +100% +Welcome to Gradle 8.8! +Here are the highlights of this release: +- Running Gradle on Java 22 +- Configurable Gradle daemon JVM +- Improved IDE performance for large projects +For more details see https://docs.gradle.org/8.8/release-notes.html +To honour the JVM settings for this build a single-use Daemon process will be forked. For more on this, please refer to https://docs.gradle.org/8.8/userguide/gradle_daemon.html#sec:disabling_the_daemon in the Gradle documentation. +Daemon will be stopped at the end of the build +> Task :gradle-plugin:settings-plugin:checkKotlinGradlePluginConfigurationErrors +> Task :gradle-plugin:settings-plugin:pluginDescriptors +> Task :gradle-plugin:settings-plugin:processResources +> Task :gradle-plugin:settings-plugin:compileKotlin +> Task :gradle-plugin:settings-plugin:compileJava NO-SOURCE +> Task :gradle-plugin:settings-plugin:classes +> Task :gradle-plugin:settings-plugin:jar +> Task :gradle-plugin:react-native-gradle-plugin:checkKotlinGradlePluginConfigurationErrors +> Task :gradle-plugin:react-native-gradle-plugin:pluginDescriptors +> Task :gradle-plugin:react-native-gradle-plugin:processResources +> Task :gradle-plugin:react-native-gradle-plugin:compileKotlin +> Task :gradle-plugin:react-native-gradle-plugin:compileJava NO-SOURCE +> Task :gradle-plugin:react-native-gradle-plugin:classes +> Task :gradle-plugin:react-native-gradle-plugin:jar +FAILURE: Build failed with an exception. +* Where: +Build file '/home/expo/workingdir/build/mobile/android/app/build.gradle' line: 1 +* What went wrong: +A problem occurred evaluating project ':app'. +> Failed to apply plugin 'com.android.internal.application'. + > Android Gradle plugin requires Java 17 to run. You are currently using Java 11. + Your current JDK is located in /usr/lib/jvm/java-11-openjdk-amd64 + You can try some of the following options: + - changing the IDE settings. + - changing the JAVA_HOME environment variable. + - changing `org.gradle.java.home` in `gradle.properties`. +* Try: +> Run with --stacktrace option to get the stack trace. +> Run with --info or --debug option to get more log output. +> Run with --scan +to get full insights. +> Get more help at https://help.gradle.org. +BUILD FAILED in 1m 11s +10 actionable tasks: 10 executed +Error: Gradle build failed with unknown error. See logs for the "Run gradlew" phase for more information. \ No newline at end of file diff --git a/attached_assets/Pasted-Running-yarn-install-production-false-in-home-expo-work_1774011638107.txt b/attached_assets/Pasted-Running-yarn-install-production-false-in-home-expo-work_1774011638107.txt new file mode 100644 index 0000000..d4247ce --- /dev/null +++ b/attached_assets/Pasted-Running-yarn-install-production-false-in-home-expo-work_1774011638107.txt @@ -0,0 +1,36 @@ +Running "yarn install --production false" in /home/expo/workingdir/build/mobile directory +yarn install v1.22.19 +info No lockfile found. +[1/4] Resolving packages... +warning expo > @expo/cli > glob@7.2.3: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me +warning expo > @expo/config > glob@7.1.6: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me +warning expo > @expo/config-plugins > glob@7.1.6: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me +warning expo > @expo/metro-config > glob@7.2.3: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me +warning expo > @expo/cli > tar@6.2.1: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me +warning expo > @expo/cli > cacache > tar@6.2.1: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me +warning expo > @expo/cli > cacache > glob@10.5.0: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me +warning expo > @expo/config > sucrase > glob@7.1.6: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me +warning expo > @expo/cli > glob > inflight@1.0.6: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. +warning expo > @expo/config > glob > inflight@1.0.6: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. +warning expo > @expo/cli > @expo/plist > @xmldom/xmldom@0.7.13: this version is no longer supported, please update to at least 0.8.* +warning expo > @expo/cli > npm-package-arg > osenv@0.1.5: This package is no longer supported. +warning expo > babel-preset-expo > @react-native/babel-preset > @babel/plugin-proposal-class-properties@7.18.6: This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-class-properties instead. +warning expo > babel-preset-expo > @react-native/babel-preset > @babel/plugin-proposal-numeric-separator@7.18.6: This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-numeric-separator instead. +warning expo > babel-preset-expo > @react-native/babel-preset > @babel/plugin-proposal-optional-chaining@7.21.0: This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-optional-chaining instead. +warning expo > babel-preset-expo > @react-native/babel-preset > @babel/plugin-proposal-object-rest-spread@7.20.7: This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-object-rest-spread instead. +warning expo > babel-preset-expo > @react-native/babel-preset > @babel/plugin-proposal-optional-catch-binding@7.18.6: This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-optional-catch-binding instead. +warning expo > babel-preset-expo > @react-native/babel-preset > @babel/plugin-proposal-async-generator-functions@7.20.7: This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-async-generator-functions instead. +warning expo > babel-preset-expo > @react-native/babel-preset > @babel/plugin-proposal-logical-assignment-operators@7.20.7: This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-logical-assignment-operators instead. +warning expo > babel-preset-expo > @react-native/babel-preset > @babel/plugin-proposal-nullish-coalescing-operator@7.18.6: This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-nullish-coalescing-operator instead. +warning expo > @expo/cli > tempy > del > rimraf@3.0.2: Rimraf versions prior to v4 are no longer supported +warning expo > @expo/cli > @react-native/dev-middleware > @rnx-kit/chromium-edge-launcher > rimraf@3.0.2: Rimraf versions prior to v4 are no longer supported +warning expo > @expo/cli > tempy > del > rimraf > glob@7.2.3: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me +warning expo > babel-preset-expo > @react-native/babel-preset > @react-native/babel-plugin-codegen > @react-native/codegen > glob@7.2.3: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me +warning expo > babel-preset-expo > @react-native/babel-preset > @react-native/babel-plugin-codegen > @react-native/codegen > jscodeshift > @babel/plugin-proposal-class-properties@7.18.6: This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-class-properties instead. +warning expo > babel-preset-expo > @react-native/babel-preset > @react-native/babel-plugin-codegen > @react-native/codegen > jscodeshift > @babel/plugin-proposal-optional-chaining@7.21.0: This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-optional-chaining instead. +warning expo > babel-preset-expo > @react-native/babel-preset > @react-native/babel-plugin-codegen > @react-native/codegen > jscodeshift > @babel/plugin-proposal-nullish-coalescing-operator@7.18.6: This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-nullish-coalescing-operator instead. +warning expo > babel-preset-expo > @react-native/babel-preset > @react-native/babel-plugin-codegen > @react-native/codegen > jscodeshift > temp > rimraf@2.6.3: Rimraf versions prior to v4 are no longer supported +warning expo > babel-preset-expo > @react-native/babel-preset > @react-native/babel-plugin-codegen > @react-native/codegen > jscodeshift > temp > rimraf > glob@7.2.3: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me +error Couldn't find any versions for "pushy-react-native" that matches "^1.4.7" +info Visit https://yarnpkg.com/en/docs/cli/install for documentation about this command. +yarn install --production false exited with non-zero code: 1 \ No newline at end of file diff --git a/attached_assets/Pasted-SSE-connection-closed-1766095325244-0-5546628837380445-_1766095421105.txt b/attached_assets/Pasted-SSE-connection-closed-1766095325244-0-5546628837380445-_1766095421105.txt new file mode 100644 index 0000000..f26d74f --- /dev/null +++ b/attached_assets/Pasted-SSE-connection-closed-1766095325244-0-5546628837380445-_1766095421105.txt @@ -0,0 +1,63 @@ +SSE connection closed: 1766095325244-0.5546628837380445 +SSE connection error 1766095325719-0.8782263161331374: Error: aborted + at abortIncoming (node:_http_server:796:17) + at socketOnClose (node:_http_server:790:3) + at Socket.emit (node:events:536:35) + at TCP. (node:net:343:12) { + code: 'ECONNRESET' +} +SSE connection closed: 1766095325719-0.8782263161331374 +10:02:06 PM [express] GET /api/notifications 304 in 151ms :: {"success":true,"notifications":[{"id":7… +10:02:07 PM [express] GET /api/bookmarks/by-folder/ 200 in 4ms +SSE connection error 1766095325812-0.5066373258011858: Error: aborted + at abortIncoming (node:_http_server:796:17) + at socketOnClose (node:_http_server:790:3) + at Socket.emit (node:events:536:35) + at TCP. (node:net:343:12) { + code: 'ECONNRESET' +} +SSE connection closed: 1766095325812-0.5066373258011858 +10:02:07 PM [express] GET /api/notifications 304 in 153ms :: {"success":true,"notifications":[{"id":7… +10:02:08 PM [express] GET /api/forms/17 304 in 150ms :: {"success":true,"form":{"id":17,"organization… +10:02:08 PM [express] GET /api/forms/17/fields 304 in 222ms :: {"success":true,"fields":[{"id":25,"fo… +10:02:08 PM [express] GET /api/forms/17/tabs 304 in 221ms :: {"success":true,"tabs":[{"id":1,"formId"… +10:02:08 PM [express] GET /api/forms/17/statuses 304 in 217ms :: {"success":true,"statuses":[{"id":32… +10:02:08 PM [express] GET /api/tasks/31/tree 304 in 222ms :: {"success":true,"task":{"id":31,"formId"… +10:02:09 PM [express] GET /api/tasks/31/field-values 304 in 293ms :: {"success":true,"fieldValues":[{… +10:02:15 PM [express] GET /api/notifications 304 in 147ms :: {"success":true,"notifications":[{"id":7… +10:02:24 PM [express] GET /api/notifications 304 in 148ms :: {"success":true,"notifications":[{"id":7… +10:02:28 PM [express] GET /api/notifications 304 in 149ms :: {"success":true,"notifications":[{"id":7… +10:02:32 PM [express] GET /api/notifications 304 in 150ms :: {"success":true,"notifications":[{"id":7… +10:02:36 PM [express] GET /api/notifications 304 in 148ms :: {"success":true,"notifications":[{"id":7… +10:02:45 PM [express] GET /api/notifications 304 in 149ms :: {"success":true,"notifications":[{"id":7… +10:02:47 PM [express] GET /api/forms/18 304 in 146ms :: {"success":true,"form":{"id":18,"organization… +10:02:47 PM [express] GET /api/forms/18/tabs 304 in 474ms :: {"success":true,"tabs":[{"id":4,"formId"… +10:02:47 PM [express] GET /api/forms/18/statuses 304 in 479ms :: {"success":true,"statuses":[{"id":35… +10:02:47 PM [express] GET /api/forms/18/fields 304 in 474ms :: {"success":true,"fields":[{"id":35,"fo… +10:02:47 PM [express] GET /api/tasks/34/field-values 304 in 520ms :: {"success":true,"fieldValues":[{… +10:02:47 PM [express] GET /api/forms/18/tasks 304 in 545ms :: {"success":true,"tasks":[{"id":35,"form… +10:02:47 PM [express] POST /api/auth/refresh 200 in 309ms :: {"success":true,"tokens":{"accessToken":… +10:02:48 PM [express] GET /api/forms 200 in 151ms :: {"success":true,"forms":[{"id":18,"organizationI… +10:02:48 PM [express] GET /api/tasks/34/tree 304 in 219ms :: {"success":true,"task":{"id":34,"formId"… +10:02:48 PM [express] GET /api/tasks/34/messages 304 in 215ms :: {"success":true,"messages":[]} +10:02:48 PM [express] GET /api/tasks/34 304 in 228ms :: {"success":true,"task":{"id":34,"formId":18,"… +10:02:48 PM [express] GET /api/forms/18/transitions 304 in 355ms :: {"success":true,"transitions":[]} +10:02:48 PM [express] GET /api/forms/17/tasks 304 in 550ms :: {"success":true,"tasks":[{"id":33,"form… +10:02:50 PM [express] GET /api/notifications 304 in 150ms :: {"success":true,"notifications":[{"id":7… +10:02:53 PM [express] GET /api/bookmarks/by-folder/ 200 in 6ms +SSE connection error 1766095373174-0.24595908801455413: Error: aborted + at abortIncoming (node:_http_server:796:17) + at socketOnClose (node:_http_server:790:3) + at Socket.emit (node:events:536:35) + at TCP. (node:net:343:12) { + code: 'ECONNRESET' +} +SSE connection closed: 1766095373174-0.24595908801455413 +10:02:53 PM [express] GET /api/bookmark-folders 304 in 152ms :: {"success":true,"folders":[{"id":2,"o… +10:02:53 PM [express] GET /api/notifications 304 in 151ms :: {"success":true,"notifications":[{"id":7… +10:02:54 PM [express] GET /api/notifications 304 in 150ms :: {"success":true,"notifications":[{"id":7… +10:02:56 PM [express] GET /api/notifications 304 in 153ms :: {"success":true,"notifications":[{"id":7… +10:02:58 PM [express] GET /api/bookmarks/by-folder/3 304 in 144ms :: {"success":true,"bookmarks":[]} +10:02:58 PM [express] GET /api/bookmarks/by-folder/2 304 in 147ms :: {"success":true,"bookmarks":[{"i… +10:02:58 PM [express] GET /api/bookmarks/by-folder/5 304 in 147ms :: {"success":true,"bookmarks":[]} +10:02:58 PM [express] GET /api/bookmarks/by-folder/10 304 in 148ms :: {"success":true,"bookmarks":[]} \ No newline at end of file diff --git a/attached_assets/TASKTITAN_AUDIT_REPORT_1778659557017.docx b/attached_assets/TASKTITAN_AUDIT_REPORT_1778659557017.docx new file mode 100644 index 0000000..0e808ac Binary files /dev/null and b/attached_assets/TASKTITAN_AUDIT_REPORT_1778659557017.docx differ diff --git a/attached_assets/TASKTITAN_REPLIT_PROMPT_v2_1778675591081.docx b/attached_assets/TASKTITAN_REPLIT_PROMPT_v2_1778675591081.docx new file mode 100644 index 0000000..c87500f Binary files /dev/null and b/attached_assets/TASKTITAN_REPLIT_PROMPT_v2_1778675591081.docx differ diff --git a/attached_assets/detailed-prompt-complete_1757967325810.md b/attached_assets/detailed-prompt-complete_1757967325810.md new file mode 100644 index 0000000..50190a6 --- /dev/null +++ b/attached_assets/detailed-prompt-complete_1757967325810.md @@ -0,0 +1,1265 @@ +# Детальный промт для создания мультитенантной системы управления рабочими процессами + +## Инструкции по общению + +**ВАЖНО**: После создания приложения **всё дальнейшее общение должно происходить на русском языке**. Интерфейс приложения также должен быть полностью на русском языке. + +## Описание проекта + +Создай корпоративную мультитенантную систему управления рабочими процессами с расширенным функционалом. Система должна поддерживать множество организаций с полной изоляцией данных. + +--- + +## ЭТАП 1: ОСНОВНАЯ АРХИТЕКТУРА И АВТОРИЗАЦИЯ + +### 🎯 Цель этапа +Создать базовую мультитенантную архитектуру с системой авторизации и управлением пользователями. + +### 📁 Структура проекта +``` +project-root/ +├── backend/ +│ ├── src/ +│ │ ├── controllers/ +│ │ │ ├── auth.controller.js +│ │ │ ├── organization.controller.js +│ │ │ └── user.controller.js +│ │ ├── middleware/ +│ │ │ ├── auth.middleware.js +│ │ │ ├── tenant.middleware.js +│ │ │ └── validation.middleware.js +│ │ ├── models/ +│ │ │ ├── Organization.js +│ │ │ ├── User.js +│ │ │ └── index.js +│ │ ├── routes/ +│ │ │ ├── auth.routes.js +│ │ │ ├── organization.routes.js +│ │ │ └── user.routes.js +│ │ ├── services/ +│ │ │ ├── auth.service.js +│ │ │ ├── email.service.js +│ │ │ └── tenant.service.js +│ │ ├── utils/ +│ │ │ ├── database.js +│ │ │ ├── logger.js +│ │ │ └── validation.js +│ │ ├── config/ +│ │ │ ├── database.config.js +│ │ │ └── jwt.config.js +│ │ └── app.js +│ ├── package.json +│ ├── Dockerfile +│ └── .env.example +├── frontend/ +│ ├── src/ +│ │ ├── components/ +│ │ │ ├── auth/ +│ │ │ │ ├── LoginForm.jsx +│ │ │ │ └── RegisterForm.jsx +│ │ │ ├── common/ +│ │ │ │ ├── Header.jsx +│ │ │ │ ├── Navigation.jsx +│ │ │ │ └── Layout.jsx +│ │ │ └── users/ +│ │ │ └── UserList.jsx +│ │ ├── pages/ +│ │ │ ├── Auth/ +│ │ │ │ ├── Login.jsx +│ │ │ │ └── Register.jsx +│ │ │ ├── Dashboard.jsx +│ │ │ └── Users.jsx +│ │ ├── services/ +│ │ │ ├── api.service.js +│ │ │ └── auth.service.js +│ │ ├── utils/ +│ │ │ ├── constants.js +│ │ │ └── helpers.js +│ │ ├── App.jsx +│ │ └── index.js +│ ├── package.json +│ └── Dockerfile +├── docker-compose.yml +└── README.md +``` + +### 🗄️ Схема базы данных PostgreSQL + +**Создай следующие таблицы:** + +```sql +-- Таблица организаций +CREATE TABLE organizations ( + id SERIAL PRIMARY KEY, + name VARCHAR(100) NOT NULL UNIQUE, + slug VARCHAR(50) NOT NULL UNIQUE, -- org1, org2 и т.д. + display_name VARCHAR(200) NOT NULL, + logo_url VARCHAR(500), + theme_color VARCHAR(7) DEFAULT '#1976d2', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + is_active BOOLEAN DEFAULT true +); + +-- Таблица пользователей +CREATE TABLE users ( + id SERIAL PRIMARY KEY, + organization_id INTEGER NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + email VARCHAR(255) NOT NULL, + password_hash VARCHAR(255) NOT NULL, + first_name VARCHAR(100) NOT NULL, + last_name VARCHAR(100) NOT NULL, + position VARCHAR(200), + role VARCHAR(20) NOT NULL DEFAULT 'user', -- 'admin', 'user' + is_active BOOLEAN DEFAULT true, + email_verified BOOLEAN DEFAULT false, + verification_token VARCHAR(255), + reset_password_token VARCHAR(255), + reset_password_expires TIMESTAMP, + last_login TIMESTAMP, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(organization_id, email) +); + +-- Таблица сессий пользователей +CREATE TABLE user_sessions ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + refresh_token VARCHAR(500) NOT NULL UNIQUE, + expires_at TIMESTAMP NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + ip_address INET, + user_agent TEXT +); + +-- Индексы для производительности +CREATE INDEX idx_organizations_slug ON organizations(slug); +CREATE INDEX idx_users_organization_email ON users(organization_id, email); +CREATE INDEX idx_users_email ON users(email); +CREATE INDEX idx_user_sessions_token ON user_sessions(refresh_token); +CREATE INDEX idx_user_sessions_user ON user_sessions(user_id); +``` + +### 🔐 JWT Авторизация - Детальная реализация + +**Backend - JWT конфигурация (config/jwt.config.js):** +```javascript +module.exports = { + accessTokenSecret: process.env.JWT_ACCESS_SECRET || 'access-secret-key', + refreshTokenSecret: process.env.JWT_REFRESH_SECRET || 'refresh-secret-key', + accessTokenExpiry: '15m', // 15 минут + refreshTokenExpiry: '7d', // 7 дней + issuer: 'workflow-system', + audience: 'workflow-users' +}; +``` + +**Middleware для авторизации (middleware/auth.middleware.js):** +```javascript +const jwt = require('jsonwebtoken'); +const { accessTokenSecret } = require('../config/jwt.config'); +const User = require('../models/User'); + +const authenticateToken = async (req, res, next) => { + const authHeader = req.headers['authorization']; + const token = authHeader && authHeader.split(' ')[1]; // Bearer TOKEN + + if (!token) { + return res.status(401).json({ error: 'Токен доступа отсутствует' }); + } + + try { + const decoded = jwt.verify(token, accessTokenSecret); + const user = await User.findById(decoded.userId); + + if (!user || !user.is_active) { + return res.status(401).json({ error: 'Пользователь не найден или заблокирован' }); + } + + req.user = user; + req.organizationId = user.organization_id; + next(); + } catch (error) { + return res.status(403).json({ error: 'Недействительный токен' }); + } +}; + +const requireAdmin = (req, res, next) => { + if (req.user.role !== 'admin') { + return res.status(403).json({ error: 'Требуются права администратора' }); + } + next(); +}; + +module.exports = { authenticateToken, requireAdmin }; +``` + +**Middleware для мультитенантности (middleware/tenant.middleware.js):** +```javascript +const tenantIsolation = (req, res, next) => { + // Добавляем organization_id во все запросы к базе данных + if (req.user && req.user.organization_id) { + req.tenantId = req.user.organization_id; + + // Модифицируем все DB запросы для изоляции данных + const originalQuery = req.query || {}; + req.query = { + ...originalQuery, + organization_id: req.tenantId + }; + } + next(); +}; + +module.exports = { tenantIsolation }; +``` + +### 🚀 API Endpoints - Детальное описание + +**Регистрация организации (POST /api/auth/register-organization):** +```javascript +// Входные данные: +{ + "organizationName": "Моя Компания", + "organizationSlug": "my-company", // только латиница и дефисы + "adminEmail": "admin@company.com", + "adminFirstName": "Иван", + "adminLastName": "Петров", + "adminPassword": "SecurePassword123!" +} + +// Ответ при успехе: +{ + "success": true, + "message": "Организация создана. Проверьте email для подтверждения.", + "organizationId": 1, + "verificationSent": true +} + +// Ответ при ошибке: +{ + "success": false, + "error": "Организация с таким именем уже существует", + "details": { + "field": "organizationSlug", + "message": "Слаг должен быть уникальным" + } +} +``` + +**Вход в систему (POST /api/auth/login):** +```javascript +// Входные данные: +{ + "email": "user@company.com", + "password": "password123", + "organizationSlug": "my-company" // опционально, если не указан - ищем по email +} + +// Ответ при успехе: +{ + "success": true, + "user": { + "id": 1, + "email": "user@company.com", + "firstName": "Иван", + "lastName": "Петров", + "role": "admin", + "organization": { + "id": 1, + "name": "Моя Компания", + "slug": "my-company" + } + }, + "tokens": { + "accessToken": "eyJhbGciOiJIUzI1NiIs...", + "refreshToken": "eyJhbGciOiJIUzI1NiIs...", + "expiresIn": 900 // секунды + } +} +``` + +**Обновление токена (POST /api/auth/refresh):** +```javascript +// Входные данные: +{ + "refreshToken": "eyJhbGciOiJIUzI1NiIs..." +} + +// Ответ: +{ + "success": true, + "tokens": { + "accessToken": "new-access-token", + "refreshToken": "new-refresh-token", + "expiresIn": 900 + } +} +``` + +### 🔒 Безопасность - Детальная реализация + +**Хэширование паролей (services/auth.service.js):** +```javascript +const bcrypt = require('bcrypt'); +const SALT_ROUNDS = 12; // Высокий уровень безопасности + +const hashPassword = async (password) => { + // Проверка сложности пароля + if (password.length < 8) { + throw new Error('Пароль должен содержать минимум 8 символов'); + } + + const hasUpperCase = /[A-Z]/.test(password); + const hasLowerCase = /[a-z]/.test(password); + const hasNumbers = /\d/.test(password); + const hasSpecialChar = /[!@#$%^&*(),.?":{}|<>]/.test(password); + + if (!hasUpperCase || !hasLowerCase || !hasNumbers || !hasSpecialChar) { + throw new Error('Пароль должен содержать заглавные и строчные буквы, цифры и специальные символы'); + } + + return await bcrypt.hash(password, SALT_ROUNDS); +}; + +const verifyPassword = async (password, hash) => { + return await bcrypt.compare(password, hash); +}; + +module.exports = { hashPassword, verifyPassword }; +``` + +**Rate Limiting (middleware/rateLimit.middleware.js):** +```javascript +const rateLimit = require('express-rate-limit'); + +// Ограничение для авторизации +const authLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, // 15 минут + max: 5, // максимум 5 попыток входа за 15 минут + message: { + error: 'Слишком много попыток входа. Попробуйте через 15 минут.' + }, + standardHeaders: true, + legacyHeaders: false +}); + +// Ограничение для регистрации +const registerLimiter = rateLimit({ + windowMs: 60 * 60 * 1000, // 1 час + max: 3, // максимум 3 регистрации в час с одного IP + message: { + error: 'Превышен лимит регистраций. Попробуйте через час.' + } +}); + +// Общий лимит для API +const apiLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, // 15 минут + max: 1000, // 1000 запросов за 15 минут + message: { + error: 'Превышен лимит запросов к API' + } +}); + +module.exports = { authLimiter, registerLimiter, apiLimiter }; +``` + +### 📧 Email сервис + +**Email отправка (services/email.service.js):** +```javascript +const nodemailer = require('nodemailer'); + +const transporter = nodemailer.createTransporter({ + host: process.env.SMTP_HOST || 'smtp.gmail.com', + port: process.env.SMTP_PORT || 587, + secure: false, + auth: { + user: process.env.SMTP_USER, + pass: process.env.SMTP_PASS + } +}); + +const sendVerificationEmail = async (email, firstName, verificationToken, organizationName) => { + const verificationUrl = `${process.env.FRONTEND_URL}/verify-email?token=${verificationToken}`; + + const mailOptions = { + from: process.env.FROM_EMAIL || 'noreply@workflow.com', + to: email, + subject: `Подтверждение email в ${organizationName}`, + html: ` +

    Добро пожаловать в ${organizationName}!

    +

    Здравствуйте, ${firstName}!

    +

    Для завершения регистрации перейдите по ссылке:

    + + Подтвердить email + +

    Ссылка действительна в течение 24 часов.

    + ` + }; + + await transporter.sendMail(mailOptions); +}; + +const sendInvitationEmail = async (email, firstName, inviterName, organizationName, tempPassword) => { + const loginUrl = `${process.env.FRONTEND_URL}/login`; + + const mailOptions = { + from: process.env.FROM_EMAIL, + to: email, + subject: `Приглашение в ${organizationName}`, + html: ` +

    Вы приглашены в ${organizationName}

    +

    Здравствуйте, ${firstName}!

    +

    ${inviterName} пригласил вас присоединиться к команде.

    +

    Временный пароль: ${tempPassword}

    +

    Обязательно смените пароль после первого входа.

    + + Войти в систему + + ` + }; + + await transporter.sendMail(mailOptions); +}; + +module.exports = { sendVerificationEmail, sendInvitationEmail }; +``` + +### 🐳 Docker конфигурация + +**docker-compose.yml:** +```yaml +version: '3.8' + +services: + postgres: + image: postgres:15 + environment: + POSTGRES_DB: workflow_system + POSTGRES_USER: postgres + POSTGRES_PASSWORD: password + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + - ./backend/init.sql:/docker-entrypoint-initdb.d/init.sql + + backend: + build: ./backend + ports: + - "3001:3001" + environment: + - NODE_ENV=development + - DATABASE_URL=postgresql://postgres:password@postgres:5432/workflow_system + - JWT_ACCESS_SECRET=your-super-secret-access-key + - JWT_REFRESH_SECRET=your-super-secret-refresh-key + - SMTP_HOST=smtp.gmail.com + - SMTP_USER=your-email@gmail.com + - SMTP_PASS=your-app-password + - FRONTEND_URL=http://localhost:3000 + depends_on: + - postgres + volumes: + - ./backend:/app + - /app/node_modules + + frontend: + build: ./frontend + ports: + - "3000:3000" + environment: + - REACT_APP_API_URL=http://localhost:3001/api + volumes: + - ./frontend:/app + - /app/node_modules + depends_on: + - backend + +volumes: + postgres_data: +``` + +**Backend Dockerfile:** +```dockerfile +FROM node:18-alpine + +WORKDIR /app + +COPY package*.json ./ +RUN npm ci --only=production + +COPY . . + +EXPOSE 3001 + +CMD ["npm", "start"] +``` + +### 🎨 Frontend компоненты - Детальная реализация + +**Форма регистрации организации (components/auth/RegisterForm.jsx):** +```jsx +import React, { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { authService } from '../../services/auth.service'; + +const RegisterForm = () => { + const [formData, setFormData] = useState({ + organizationName: '', + organizationSlug: '', + adminEmail: '', + adminFirstName: '', + adminLastName: '', + adminPassword: '', + confirmPassword: '' + }); + const [errors, setErrors] = useState({}); + const [loading, setLoading] = useState(false); + const navigate = useNavigate(); + + const validateForm = () => { + const newErrors = {}; + + if (!formData.organizationName.trim()) { + newErrors.organizationName = 'Название организации обязательно'; + } + + if (!formData.organizationSlug.trim()) { + newErrors.organizationSlug = 'Код организации обязателен'; + } else if (!/^[a-z0-9-]+$/.test(formData.organizationSlug)) { + newErrors.organizationSlug = 'Код может содержать только латинские буквы, цифры и дефисы'; + } + + if (!formData.adminEmail.trim()) { + newErrors.adminEmail = 'Email обязателен'; + } else if (!/\S+@\S+\.\S+/.test(formData.adminEmail)) { + newErrors.adminEmail = 'Неверный формат email'; + } + + if (!formData.adminFirstName.trim()) { + newErrors.adminFirstName = 'Имя обязательно'; + } + + if (!formData.adminLastName.trim()) { + newErrors.adminLastName = 'Фамилия обязательна'; + } + + if (!formData.adminPassword) { + newErrors.adminPassword = 'Пароль обязателен'; + } else if (formData.adminPassword.length < 8) { + newErrors.adminPassword = 'Пароль должен содержать минимум 8 символов'; + } + + if (formData.adminPassword !== formData.confirmPassword) { + newErrors.confirmPassword = 'Пароли не совпадают'; + } + + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }; + + const handleSubmit = async (e) => { + e.preventDefault(); + + if (!validateForm()) return; + + setLoading(true); + try { + await authService.registerOrganization(formData); + alert('Организация создана! Проверьте email для подтверждения.'); + navigate('/login'); + } catch (error) { + setErrors({ submit: error.message }); + } finally { + setLoading(false); + } + }; + + const handleChange = (e) => { + const { name, value } = e.target; + setFormData(prev => ({ ...prev, [name]: value })); + + // Автоматическое создание slug из названия организации + if (name === 'organizationName') { + const slug = value + .toLowerCase() + .replace(/[^a-z0-9\s-]/g, '') + .replace(/\s+/g, '-') + .substring(0, 50); + setFormData(prev => ({ ...prev, organizationSlug: slug })); + } + + // Очистка ошибок при изменении поля + if (errors[name]) { + setErrors(prev => ({ ...prev, [name]: '' })); + } + }; + + return ( +
    +

    Создание организации

    +
    +
    + + + {errors.organizationName && {errors.organizationName}} +
    + +
    + + + {errors.organizationSlug && {errors.organizationSlug}} +
    + +
    + + + {errors.adminEmail && {errors.adminEmail}} +
    + +
    +
    + + + {errors.adminFirstName && {errors.adminFirstName}} +
    + +
    + + + {errors.adminLastName && {errors.adminLastName}} +
    +
    + +
    + + + {errors.adminPassword && {errors.adminPassword}} +
    + +
    + + + {errors.confirmPassword && {errors.confirmPassword}} +
    + + {errors.submit &&
    {errors.submit}
    } + + + +
    + ); +}; + +export default RegisterForm; +``` + +### ✅ Критерии завершения Этапа 1 + +**Функциональные требования:** +1. ✅ PostgreSQL база данных настроена и работает +2. ✅ Таблицы organizations, users, user_sessions созданы +3. ✅ JWT авторизация с access и refresh токенами работает +4. ✅ Регистрация организации с email подтверждением +5. ✅ Вход в систему с проверкой организации +6. ✅ Middleware для мультитенантности изолирует данные +7. ✅ Rate limiting защищает от брутфорса +8. ✅ Админ может добавлять пользователей в организацию +9. ✅ Frontend формы регистрации и входа работают +10. ✅ Docker контейнеры запускаются и работают + +**Технические требования:** +1. ✅ API endpoints отвечают согласно спецификации +2. ✅ Валидация входных данных на backend и frontend +3. ✅ Ошибки обрабатываются и логируются +4. ✅ CORS настроен правильно +5. ✅ Environment переменные используются +6. ✅ Пароли хэшируются с bcrypt +7. ✅ Email отправка работает +8. ✅ Мультитенантность изолирует данные между организациями + +**Тестирование:** +1. Зарегистрируй 2 разные организации +2. Создай пользователей в каждой организации +3. Убедись, что пользователи видят только данные своей организации +4. Проверь, что неавторизованные запросы блокируются +5. Проверь работу refresh токенов + +--- + +## ЭТАП 2: СИСТЕМА ФОРМ И ЗАДАЧ + +### 🎯 Цель этапа +Создать систему динамических форм с различными типами полей и систему задач на основе этих форм. + +### 🗄️ Расширение схемы базы данных + +```sql +-- Таблица форм +CREATE TABLE forms ( + id SERIAL PRIMARY KEY, + organization_id INTEGER NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + name VARCHAR(200) NOT NULL, + description TEXT, + created_by INTEGER NOT NULL REFERENCES users(id), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + is_active BOOLEAN DEFAULT true +); + +-- Таблица полей форм +CREATE TABLE form_fields ( + id SERIAL PRIMARY KEY, + form_id INTEGER NOT NULL REFERENCES forms(id) ON DELETE CASCADE, + name VARCHAR(200) NOT NULL, + code VARCHAR(100) NOT NULL, -- уникальный код поля внутри формы + type VARCHAR(50) NOT NULL, -- text, number, date, select, checkbox, toggle, button, user, file, textarea, rich-text, datetime, history-number + is_required BOOLEAN DEFAULT false, + placeholder TEXT, + default_value TEXT, + validation_rules JSONB, -- правила валидации в формате JSON + options JSONB, -- опции для select полей + position INTEGER NOT NULL DEFAULT 0, -- порядок отображения + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(form_id, code) +); + +-- Таблица статусов форм +CREATE TABLE form_statuses ( + id SERIAL PRIMARY KEY, + form_id INTEGER NOT NULL REFERENCES forms(id) ON DELETE CASCADE, + name VARCHAR(100) NOT NULL, + color VARCHAR(7) NOT NULL DEFAULT '#e5e7eb', + position INTEGER NOT NULL DEFAULT 0, + is_initial BOOLEAN DEFAULT false, -- начальный статус + is_final BOOLEAN DEFAULT false, -- финальный статус + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Таблица правил переходов между статусами +CREATE TABLE status_transitions ( + id SERIAL PRIMARY KEY, + form_id INTEGER NOT NULL REFERENCES forms(id) ON DELETE CASCADE, + from_status_id INTEGER NOT NULL REFERENCES form_statuses(id), + to_status_id INTEGER NOT NULL REFERENCES form_statuses(id), + action_type VARCHAR(20) NOT NULL, -- 'approve', 'reject', 'custom' + action_name VARCHAR(100), -- название кнопки действия + conditions JSONB, -- условия для перехода + required_role VARCHAR(20), -- роль, необходимая для действия + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Таблица задач +CREATE TABLE tasks ( + id SERIAL PRIMARY KEY, + organization_id INTEGER NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + form_id INTEGER NOT NULL REFERENCES forms(id), + title VARCHAR(500) NOT NULL, + description TEXT, + current_status_id INTEGER NOT NULL REFERENCES form_statuses(id), + assigned_to INTEGER REFERENCES users(id), -- ответственный + created_by INTEGER NOT NULL REFERENCES users(id), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + completed_at TIMESTAMP, + due_date TIMESTAMP -- дедлайн +); + +-- Таблица значений полей задач +CREATE TABLE task_field_values ( + id SERIAL PRIMARY KEY, + task_id INTEGER NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, + field_id INTEGER NOT NULL REFERENCES form_fields(id), + value JSONB, -- значение поля в JSON формате + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(task_id, field_id) +); + +-- Таблица истории изменений для history-number полей +CREATE TABLE field_history ( + id SERIAL PRIMARY KEY, + task_id INTEGER NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, + field_id INTEGER NOT NULL REFERENCES form_fields(id), + old_value JSONB, + new_value JSONB, + changed_by INTEGER NOT NULL REFERENCES users(id), + changed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Индексы для производительности +CREATE INDEX idx_forms_organization ON forms(organization_id); +CREATE INDEX idx_form_fields_form ON form_fields(form_id); +CREATE INDEX idx_form_statuses_form ON form_statuses(form_id); +CREATE INDEX idx_tasks_organization ON tasks(organization_id); +CREATE INDEX idx_tasks_form ON tasks(form_id); +CREATE INDEX idx_tasks_status ON tasks(current_status_id); +CREATE INDEX idx_tasks_assigned ON tasks(assigned_to); +CREATE INDEX idx_task_field_values_task ON task_field_values(task_id); +CREATE INDEX idx_field_history_task_field ON field_history(task_id, field_id); +``` + +### 📝 Типы полей - Детальная реализация + +**Валидация для каждого типа поля (utils/fieldValidation.js):** +```javascript +const fieldValidators = { + text: (value, rules = {}) => { + if (!value && rules.required) return 'Поле обязательно для заполнения'; + if (value && rules.minLength && value.length < rules.minLength) { + return `Минимальная длина: ${rules.minLength} символов`; + } + if (value && rules.maxLength && value.length > rules.maxLength) { + return `Максимальная длина: ${rules.maxLength} символов`; + } + if (value && rules.pattern && !new RegExp(rules.pattern).test(value)) { + return rules.patternMessage || 'Неверный формат'; + } + return null; + }, + + number: (value, rules = {}) => { + if (!value && rules.required) return 'Поле обязательно для заполнения'; + if (value !== null && value !== undefined) { + const num = Number(value); + if (isNaN(num)) return 'Должно быть числом'; + if (rules.min !== undefined && num < rules.min) { + return `Минимальное значение: ${rules.min}`; + } + if (rules.max !== undefined && num > rules.max) { + return `Максимальное значение: ${rules.max}`; + } + } + return null; + }, + + date: (value, rules = {}) => { + if (!value && rules.required) return 'Поле обязательно для заполнения'; + if (value) { + const date = new Date(value); + if (isNaN(date.getTime())) return 'Неверный формат даты'; + if (rules.minDate && date < new Date(rules.minDate)) { + return `Дата не может быть раньше ${rules.minDate}`; + } + if (rules.maxDate && date > new Date(rules.maxDate)) { + return `Дата не может быть позже ${rules.maxDate}`; + } + } + return null; + }, + + datetime: (value, rules = {}) => { + return fieldValidators.date(value, rules); // Та же валидация что и для date + }, + + select: (value, rules = {}, options = []) => { + if (!value && rules.required) return 'Поле обязательно для заполнения'; + if (value && options.length > 0) { + const validOption = options.find(opt => opt.id === value); + if (!validOption) return 'Неверное значение'; + } + return null; + }, + + checkbox: (value, rules = {}) => { + if (rules.required && !value) return 'Поле должно быть отмечено'; + return null; + }, + + toggle: (value, rules = {}) => { + return fieldValidators.checkbox(value, rules); + }, + + user: (value, rules = {}) => { + if (!value && rules.required) return 'Необходимо выбрать пользователя'; + if (value && !Number.isInteger(Number(value))) { + return 'Неверный ID пользователя'; + } + return null; + }, + + file: (value, rules = {}) => { + if (!value && rules.required) return 'Необходимо загрузить файл'; + if (value && rules.maxFileSize && value.size > rules.maxFileSize) { + return `Максимальный размер файла: ${rules.maxFileSize / 1024 / 1024} МБ`; + } + if (value && rules.allowedTypes && !rules.allowedTypes.includes(value.type)) { + return `Разрешенные типы файлов: ${rules.allowedTypes.join(', ')}`; + } + return null; + }, + + textarea: (value, rules = {}) => { + return fieldValidators.text(value, rules); + }, + + 'rich-text': (value, rules = {}) => { + // Проверяем HTML контент + if (!value && rules.required) return 'Поле обязательно для заполнения'; + if (value && rules.maxLength) { + // Удаляем HTML теги для подсчета длины текста + const textContent = value.replace(/<[^>]*>/g, ''); + if (textContent.length > rules.maxLength) { + return `Максимальная длина текста: ${rules.maxLength} символов`; + } + } + return null; + }, + + 'history-number': (value, rules = {}) => { + return fieldValidators.number(value, rules); + } +}; + +const validateFieldValue = (fieldType, value, validationRules, options) => { + const validator = fieldValidators[fieldType]; + if (!validator) { + throw new Error(`Неизвестный тип поля: ${fieldType}`); + } + return validator(value, validationRules, options); +}; + +module.exports = { validateFieldValue, fieldValidators }; +``` + +### 🔧 API для форм - Детальная реализация + +**CRUD операции для форм (controllers/forms.controller.js):** +```javascript +const Form = require('../models/Form'); +const FormField = require('../models/FormField'); +const FormStatus = require('../models/FormStatus'); +const { validateFieldValue } = require('../utils/fieldValidation'); + +// Получить все формы организации +const getForms = async (req, res) => { + try { + const { page = 1, limit = 20, search } = req.query; + const offset = (page - 1) * limit; + + let whereClause = { organization_id: req.tenantId, is_active: true }; + + if (search) { + whereClause.name = { [Op.iLike]: `%${search}%` }; + } + + const { rows: forms, count } = await Form.findAndCountAll({ + where: whereClause, + include: [ + { + model: FormField, + as: 'fields', + order: [['position', 'ASC']] + }, + { + model: FormStatus, + as: 'statuses', + order: [['position', 'ASC']] + } + ], + limit: parseInt(limit), + offset: parseInt(offset), + order: [['created_at', 'DESC']] + }); + + res.json({ + success: true, + data: forms, + pagination: { + page: parseInt(page), + limit: parseInt(limit), + total: count, + pages: Math.ceil(count / limit) + } + }); + } catch (error) { + console.error('Ошибка получения форм:', error); + res.status(500).json({ success: false, error: 'Ошибка сервера' }); + } +}; + +// Создать новую форму +const createForm = async (req, res) => { + const transaction = await sequelize.transaction(); + + try { + const { name, description, fields = [], statuses = [] } = req.body; + + // Валидация входных данных + if (!name || name.trim().length === 0) { + return res.status(400).json({ + success: false, + error: 'Название формы обязательно' + }); + } + + // Создаем форму + const form = await Form.create({ + organization_id: req.tenantId, + name: name.trim(), + description: description?.trim(), + created_by: req.user.id + }, { transaction }); + + // Создаем поля формы + const createdFields = []; + for (let i = 0; i < fields.length; i++) { + const field = fields[i]; + + // Валидация поля + if (!field.name || !field.type) { + throw new Error(`Поле ${i + 1}: название и тип обязательны`); + } + + const createdField = await FormField.create({ + form_id: form.id, + name: field.name.trim(), + code: field.code || `field_${i + 1}`, + type: field.type, + is_required: field.is_required || false, + placeholder: field.placeholder, + default_value: field.default_value, + validation_rules: field.validation_rules || {}, + options: field.options || [], + position: i + }, { transaction }); + + createdFields.push(createdField); + } + + // Создаем статусы (если не указаны, создаем по умолчанию) + const defaultStatuses = statuses.length > 0 ? statuses : [ + { name: 'Создана', color: '#e5e7eb', is_initial: true }, + { name: 'В работе', color: '#bfdbfe' }, + { name: 'Завершена', color: '#d1fae5', is_final: true } + ]; + + const createdStatuses = []; + for (let i = 0; i < defaultStatuses.length; i++) { + const status = defaultStatuses[i]; + + const createdStatus = await FormStatus.create({ + form_id: form.id, + name: status.name, + color: status.color || '#e5e7eb', + position: i, + is_initial: i === 0 || status.is_initial, + is_final: i === defaultStatuses.length - 1 || status.is_final + }, { transaction }); + + createdStatuses.push(createdStatus); + } + + await transaction.commit(); + + // Возвращаем созданную форму с полями и статусами + const fullForm = await Form.findByPk(form.id, { + include: [ + { model: FormField, as: 'fields', order: [['position', 'ASC']] }, + { model: FormStatus, as: 'statuses', order: [['position', 'ASC']] } + ] + }); + + res.status(201).json({ + success: true, + data: fullForm, + message: 'Форма успешно создана' + }); + + } catch (error) { + await transaction.rollback(); + console.error('Ошибка создания формы:', error); + res.status(400).json({ + success: false, + error: error.message || 'Ошибка создания формы' + }); + } +}; + +// Обновить форму +const updateForm = async (req, res) => { + const transaction = await sequelize.transaction(); + + try { + const { id } = req.params; + const { name, description, fields, statuses } = req.body; + + // Находим форму + const form = await Form.findOne({ + where: { id, organization_id: req.tenantId } + }); + + if (!form) { + return res.status(404).json({ + success: false, + error: 'Форма не найдена' + }); + } + + // Обновляем основную информацию формы + if (name) form.name = name.trim(); + if (description !== undefined) form.description = description?.trim(); + form.updated_at = new Date(); + + await form.save({ transaction }); + + // Обновляем поля, если переданы + if (fields && Array.isArray(fields)) { + // Удаляем старые поля + await FormField.destroy({ + where: { form_id: form.id }, + transaction + }); + + // Создаем новые поля + for (let i = 0; i < fields.length; i++) { + const field = fields[i]; + await FormField.create({ + form_id: form.id, + name: field.name.trim(), + code: field.code || `field_${i + 1}`, + type: field.type, + is_required: field.is_required || false, + placeholder: field.placeholder, + default_value: field.default_value, + validation_rules: field.validation_rules || {}, + options: field.options || [], + position: i + }, { transaction }); + } + } + + // Обновляем статусы, если переданы + if (statuses && Array.isArray(statuses)) { + // Удаляем старые статусы + await FormStatus.destroy({ + where: { form_id: form.id }, + transaction + }); + + // Создаем новые статусы + for (let i = 0; i < statuses.length; i++) { + const status = statuses[i]; + await FormStatus.create({ + form_id: form.id, + name: status.name, + color: status.color || '#e5e7eb', + position: i, + is_initial: i === 0 || status.is_initial, + is_final: i === statuses.length - 1 || status.is_final + }, { transaction }); + } + } + + await transaction.commit(); + + // Возвращаем обновленную форму + const updatedForm = await Form.findByPk(form.id, { + include: [ + { model: FormField, as: 'fields', order: [['position', 'ASC']] }, + { model: FormStatus, as: 'statuses', order: [['position', 'ASC']] } + ] + }); + + res.json({ + success: true, + data: updatedForm, + message: 'Форма успешно обновлена' + }); + + } catch (error) { + await transaction.rollback(); + console.error('Ошибка обновления формы:', error); + res.status(400).json({ + success: false, + error: error.message || 'Ошибка обновления формы' + }); + } +}; + +module.exports = { + getForms, + createForm, + updateForm, + // ... другие методы +}; +``` + +### ✅ Критерии завершения Этапа 2 + +**Функциональные требования:** +1. ✅ Создание форм с различными типами полей +2. ✅ Валидация полей согласно их типам +3. ✅ Настройка статусов форм с цветами +4. ✅ Правила переходов между статусами +5. ✅ Создание задач на основе форм +6. ✅ Назначение ответственных за задачи +7. ✅ Заполнение и редактирование полей задач +8. ✅ История изменений для history-number полей +9. ✅ Пагинация для всех списков +10. ✅ Фильтрация и поиск форм/задач + +**Продолжение следует в Этапе 3...** + +--- + +Хотите ли вы, чтобы я продолжил с детальным описанием Этапов 3 и 4? Это будет включать: +- Канбан доски с drag & drop +- Систему комментариев с упоминаниями +- Файловые вложения +- Real-time уведомления +- Отчетность и аналитику \ No newline at end of file diff --git a/attached_assets/detailed-prompt-complete_1757999077295.md b/attached_assets/detailed-prompt-complete_1757999077295.md new file mode 100644 index 0000000..3076ee2 --- /dev/null +++ b/attached_assets/detailed-prompt-complete_1757999077295.md @@ -0,0 +1,518 @@ +# Детальный промт для создания мультитенантной системы управления рабочими процессами + + +--- + +## ЭТАП 2: СИСТЕМА ФОРМ И ЗАДАЧ + +### 🎯 Цель этапа +Создать систему динамических форм с различными типами полей и систему задач на основе этих форм. + +### 🗄️ Расширение схемы базы данных + +```sql +-- Таблица форм +CREATE TABLE forms ( + id SERIAL PRIMARY KEY, + organization_id INTEGER NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + name VARCHAR(200) NOT NULL, + description TEXT, + created_by INTEGER NOT NULL REFERENCES users(id), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + is_active BOOLEAN DEFAULT true +); + +-- Таблица полей форм +CREATE TABLE form_fields ( + id SERIAL PRIMARY KEY, + form_id INTEGER NOT NULL REFERENCES forms(id) ON DELETE CASCADE, + name VARCHAR(200) NOT NULL, + code VARCHAR(100) NOT NULL, -- уникальный код поля внутри формы + type VARCHAR(50) NOT NULL, -- text, number, date, select, checkbox, toggle, button, user, file, textarea, rich-text, datetime, history-number + is_required BOOLEAN DEFAULT false, + placeholder TEXT, + default_value TEXT, + validation_rules JSONB, -- правила валидации в формате JSON + options JSONB, -- опции для select полей + position INTEGER NOT NULL DEFAULT 0, -- порядок отображения + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(form_id, code) +); + +-- Таблица статусов форм +CREATE TABLE form_statuses ( + id SERIAL PRIMARY KEY, + form_id INTEGER NOT NULL REFERENCES forms(id) ON DELETE CASCADE, + name VARCHAR(100) NOT NULL, + color VARCHAR(7) NOT NULL DEFAULT '#e5e7eb', + position INTEGER NOT NULL DEFAULT 0, + is_initial BOOLEAN DEFAULT false, -- начальный статус + is_final BOOLEAN DEFAULT false, -- финальный статус + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Таблица правил переходов между статусами +CREATE TABLE status_transitions ( + id SERIAL PRIMARY KEY, + form_id INTEGER NOT NULL REFERENCES forms(id) ON DELETE CASCADE, + from_status_id INTEGER NOT NULL REFERENCES form_statuses(id), + to_status_id INTEGER NOT NULL REFERENCES form_statuses(id), + action_type VARCHAR(20) NOT NULL, -- 'approve', 'reject', 'custom' + action_name VARCHAR(100), -- название кнопки действия + conditions JSONB, -- условия для перехода + required_role VARCHAR(20), -- роль, необходимая для действия + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Таблица задач +CREATE TABLE tasks ( + id SERIAL PRIMARY KEY, + organization_id INTEGER NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + form_id INTEGER NOT NULL REFERENCES forms(id), + title VARCHAR(500) NOT NULL, + description TEXT, + current_status_id INTEGER NOT NULL REFERENCES form_statuses(id), + assigned_to INTEGER REFERENCES users(id), -- ответственный + created_by INTEGER NOT NULL REFERENCES users(id), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + completed_at TIMESTAMP, + due_date TIMESTAMP -- дедлайн +); + +-- Таблица значений полей задач +CREATE TABLE task_field_values ( + id SERIAL PRIMARY KEY, + task_id INTEGER NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, + field_id INTEGER NOT NULL REFERENCES form_fields(id), + value JSONB, -- значение поля в JSON формате + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(task_id, field_id) +); + +-- Таблица истории изменений для history-number полей +CREATE TABLE field_history ( + id SERIAL PRIMARY KEY, + task_id INTEGER NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, + field_id INTEGER NOT NULL REFERENCES form_fields(id), + old_value JSONB, + new_value JSONB, + changed_by INTEGER NOT NULL REFERENCES users(id), + changed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Индексы для производительности +CREATE INDEX idx_forms_organization ON forms(organization_id); +CREATE INDEX idx_form_fields_form ON form_fields(form_id); +CREATE INDEX idx_form_statuses_form ON form_statuses(form_id); +CREATE INDEX idx_tasks_organization ON tasks(organization_id); +CREATE INDEX idx_tasks_form ON tasks(form_id); +CREATE INDEX idx_tasks_status ON tasks(current_status_id); +CREATE INDEX idx_tasks_assigned ON tasks(assigned_to); +CREATE INDEX idx_task_field_values_task ON task_field_values(task_id); +CREATE INDEX idx_field_history_task_field ON field_history(task_id, field_id); +``` + +### 📝 Типы полей - Детальная реализация + +**Валидация для каждого типа поля (utils/fieldValidation.js):** +```javascript +const fieldValidators = { + text: (value, rules = {}) => { + if (!value && rules.required) return 'Поле обязательно для заполнения'; + if (value && rules.minLength && value.length < rules.minLength) { + return `Минимальная длина: ${rules.minLength} символов`; + } + if (value && rules.maxLength && value.length > rules.maxLength) { + return `Максимальная длина: ${rules.maxLength} символов`; + } + if (value && rules.pattern && !new RegExp(rules.pattern).test(value)) { + return rules.patternMessage || 'Неверный формат'; + } + return null; + }, + + number: (value, rules = {}) => { + if (!value && rules.required) return 'Поле обязательно для заполнения'; + if (value !== null && value !== undefined) { + const num = Number(value); + if (isNaN(num)) return 'Должно быть числом'; + if (rules.min !== undefined && num < rules.min) { + return `Минимальное значение: ${rules.min}`; + } + if (rules.max !== undefined && num > rules.max) { + return `Максимальное значение: ${rules.max}`; + } + } + return null; + }, + + date: (value, rules = {}) => { + if (!value && rules.required) return 'Поле обязательно для заполнения'; + if (value) { + const date = new Date(value); + if (isNaN(date.getTime())) return 'Неверный формат даты'; + if (rules.minDate && date < new Date(rules.minDate)) { + return `Дата не может быть раньше ${rules.minDate}`; + } + if (rules.maxDate && date > new Date(rules.maxDate)) { + return `Дата не может быть позже ${rules.maxDate}`; + } + } + return null; + }, + + datetime: (value, rules = {}) => { + return fieldValidators.date(value, rules); // Та же валидация что и для date + }, + + select: (value, rules = {}, options = []) => { + if (!value && rules.required) return 'Поле обязательно для заполнения'; + if (value && options.length > 0) { + const validOption = options.find(opt => opt.id === value); + if (!validOption) return 'Неверное значение'; + } + return null; + }, + + checkbox: (value, rules = {}) => { + if (rules.required && !value) return 'Поле должно быть отмечено'; + return null; + }, + + toggle: (value, rules = {}) => { + return fieldValidators.checkbox(value, rules); + }, + + user: (value, rules = {}) => { + if (!value && rules.required) return 'Необходимо выбрать пользователя'; + if (value && !Number.isInteger(Number(value))) { + return 'Неверный ID пользователя'; + } + return null; + }, + + file: (value, rules = {}) => { + if (!value && rules.required) return 'Необходимо загрузить файл'; + if (value && rules.maxFileSize && value.size > rules.maxFileSize) { + return `Максимальный размер файла: ${rules.maxFileSize / 1024 / 1024} МБ`; + } + if (value && rules.allowedTypes && !rules.allowedTypes.includes(value.type)) { + return `Разрешенные типы файлов: ${rules.allowedTypes.join(', ')}`; + } + return null; + }, + + textarea: (value, rules = {}) => { + return fieldValidators.text(value, rules); + }, + + 'rich-text': (value, rules = {}) => { + // Проверяем HTML контент + if (!value && rules.required) return 'Поле обязательно для заполнения'; + if (value && rules.maxLength) { + // Удаляем HTML теги для подсчета длины текста + const textContent = value.replace(/<[^>]*>/g, ''); + if (textContent.length > rules.maxLength) { + return `Максимальная длина текста: ${rules.maxLength} символов`; + } + } + return null; + }, + + 'history-number': (value, rules = {}) => { + return fieldValidators.number(value, rules); + } +}; + +const validateFieldValue = (fieldType, value, validationRules, options) => { + const validator = fieldValidators[fieldType]; + if (!validator) { + throw new Error(`Неизвестный тип поля: ${fieldType}`); + } + return validator(value, validationRules, options); +}; + +module.exports = { validateFieldValue, fieldValidators }; +``` + +### 🔧 API для форм - Детальная реализация + +**CRUD операции для форм (controllers/forms.controller.js):** +```javascript +const Form = require('../models/Form'); +const FormField = require('../models/FormField'); +const FormStatus = require('../models/FormStatus'); +const { validateFieldValue } = require('../utils/fieldValidation'); + +// Получить все формы организации +const getForms = async (req, res) => { + try { + const { page = 1, limit = 20, search } = req.query; + const offset = (page - 1) * limit; + + let whereClause = { organization_id: req.tenantId, is_active: true }; + + if (search) { + whereClause.name = { [Op.iLike]: `%${search}%` }; + } + + const { rows: forms, count } = await Form.findAndCountAll({ + where: whereClause, + include: [ + { + model: FormField, + as: 'fields', + order: [['position', 'ASC']] + }, + { + model: FormStatus, + as: 'statuses', + order: [['position', 'ASC']] + } + ], + limit: parseInt(limit), + offset: parseInt(offset), + order: [['created_at', 'DESC']] + }); + + res.json({ + success: true, + data: forms, + pagination: { + page: parseInt(page), + limit: parseInt(limit), + total: count, + pages: Math.ceil(count / limit) + } + }); + } catch (error) { + console.error('Ошибка получения форм:', error); + res.status(500).json({ success: false, error: 'Ошибка сервера' }); + } +}; + +// Создать новую форму +const createForm = async (req, res) => { + const transaction = await sequelize.transaction(); + + try { + const { name, description, fields = [], statuses = [] } = req.body; + + // Валидация входных данных + if (!name || name.trim().length === 0) { + return res.status(400).json({ + success: false, + error: 'Название формы обязательно' + }); + } + + // Создаем форму + const form = await Form.create({ + organization_id: req.tenantId, + name: name.trim(), + description: description?.trim(), + created_by: req.user.id + }, { transaction }); + + // Создаем поля формы + const createdFields = []; + for (let i = 0; i < fields.length; i++) { + const field = fields[i]; + + // Валидация поля + if (!field.name || !field.type) { + throw new Error(`Поле ${i + 1}: название и тип обязательны`); + } + + const createdField = await FormField.create({ + form_id: form.id, + name: field.name.trim(), + code: field.code || `field_${i + 1}`, + type: field.type, + is_required: field.is_required || false, + placeholder: field.placeholder, + default_value: field.default_value, + validation_rules: field.validation_rules || {}, + options: field.options || [], + position: i + }, { transaction }); + + createdFields.push(createdField); + } + + // Создаем статусы (если не указаны, создаем по умолчанию) + const defaultStatuses = statuses.length > 0 ? statuses : [ + { name: 'Создана', color: '#e5e7eb', is_initial: true }, + { name: 'В работе', color: '#bfdbfe' }, + { name: 'Завершена', color: '#d1fae5', is_final: true } + ]; + + const createdStatuses = []; + for (let i = 0; i < defaultStatuses.length; i++) { + const status = defaultStatuses[i]; + + const createdStatus = await FormStatus.create({ + form_id: form.id, + name: status.name, + color: status.color || '#e5e7eb', + position: i, + is_initial: i === 0 || status.is_initial, + is_final: i === defaultStatuses.length - 1 || status.is_final + }, { transaction }); + + createdStatuses.push(createdStatus); + } + + await transaction.commit(); + + // Возвращаем созданную форму с полями и статусами + const fullForm = await Form.findByPk(form.id, { + include: [ + { model: FormField, as: 'fields', order: [['position', 'ASC']] }, + { model: FormStatus, as: 'statuses', order: [['position', 'ASC']] } + ] + }); + + res.status(201).json({ + success: true, + data: fullForm, + message: 'Форма успешно создана' + }); + + } catch (error) { + await transaction.rollback(); + console.error('Ошибка создания формы:', error); + res.status(400).json({ + success: false, + error: error.message || 'Ошибка создания формы' + }); + } +}; + +// Обновить форму +const updateForm = async (req, res) => { + const transaction = await sequelize.transaction(); + + try { + const { id } = req.params; + const { name, description, fields, statuses } = req.body; + + // Находим форму + const form = await Form.findOne({ + where: { id, organization_id: req.tenantId } + }); + + if (!form) { + return res.status(404).json({ + success: false, + error: 'Форма не найдена' + }); + } + + // Обновляем основную информацию формы + if (name) form.name = name.trim(); + if (description !== undefined) form.description = description?.trim(); + form.updated_at = new Date(); + + await form.save({ transaction }); + + // Обновляем поля, если переданы + if (fields && Array.isArray(fields)) { + // Удаляем старые поля + await FormField.destroy({ + where: { form_id: form.id }, + transaction + }); + + // Создаем новые поля + for (let i = 0; i < fields.length; i++) { + const field = fields[i]; + await FormField.create({ + form_id: form.id, + name: field.name.trim(), + code: field.code || `field_${i + 1}`, + type: field.type, + is_required: field.is_required || false, + placeholder: field.placeholder, + default_value: field.default_value, + validation_rules: field.validation_rules || {}, + options: field.options || [], + position: i + }, { transaction }); + } + } + + // Обновляем статусы, если переданы + if (statuses && Array.isArray(statuses)) { + // Удаляем старые статусы + await FormStatus.destroy({ + where: { form_id: form.id }, + transaction + }); + + // Создаем новые статусы + for (let i = 0; i < statuses.length; i++) { + const status = statuses[i]; + await FormStatus.create({ + form_id: form.id, + name: status.name, + color: status.color || '#e5e7eb', + position: i, + is_initial: i === 0 || status.is_initial, + is_final: i === statuses.length - 1 || status.is_final + }, { transaction }); + } + } + + await transaction.commit(); + + // Возвращаем обновленную форму + const updatedForm = await Form.findByPk(form.id, { + include: [ + { model: FormField, as: 'fields', order: [['position', 'ASC']] }, + { model: FormStatus, as: 'statuses', order: [['position', 'ASC']] } + ] + }); + + res.json({ + success: true, + data: updatedForm, + message: 'Форма успешно обновлена' + }); + + } catch (error) { + await transaction.rollback(); + console.error('Ошибка обновления формы:', error); + res.status(400).json({ + success: false, + error: error.message || 'Ошибка обновления формы' + }); + } +}; + +module.exports = { + getForms, + createForm, + updateForm, + // ... другие методы +}; +``` + +### ✅ Критерии завершения Этапа 2 + +**Функциональные требования:** +1. ✅ Создание форм с различными типами полей +2. ✅ Валидация полей согласно их типам +3. ✅ Настройка статусов форм с цветами +4. ✅ Правила переходов между статусами +5. ✅ Создание задач на основе форм +6. ✅ Назначение ответственных за задачи +7. ✅ Заполнение и редактирование полей задач +8. ✅ История изменений для history-number полей +9. ✅ Пагинация для всех списков +10. ✅ Фильтрация и поиск форм/задач + +**Продолжение следует в Этапе 3...** diff --git a/attached_assets/detailed-prompt-complete_1758010511902.md b/attached_assets/detailed-prompt-complete_1758010511902.md new file mode 100644 index 0000000..3076ee2 --- /dev/null +++ b/attached_assets/detailed-prompt-complete_1758010511902.md @@ -0,0 +1,518 @@ +# Детальный промт для создания мультитенантной системы управления рабочими процессами + + +--- + +## ЭТАП 2: СИСТЕМА ФОРМ И ЗАДАЧ + +### 🎯 Цель этапа +Создать систему динамических форм с различными типами полей и систему задач на основе этих форм. + +### 🗄️ Расширение схемы базы данных + +```sql +-- Таблица форм +CREATE TABLE forms ( + id SERIAL PRIMARY KEY, + organization_id INTEGER NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + name VARCHAR(200) NOT NULL, + description TEXT, + created_by INTEGER NOT NULL REFERENCES users(id), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + is_active BOOLEAN DEFAULT true +); + +-- Таблица полей форм +CREATE TABLE form_fields ( + id SERIAL PRIMARY KEY, + form_id INTEGER NOT NULL REFERENCES forms(id) ON DELETE CASCADE, + name VARCHAR(200) NOT NULL, + code VARCHAR(100) NOT NULL, -- уникальный код поля внутри формы + type VARCHAR(50) NOT NULL, -- text, number, date, select, checkbox, toggle, button, user, file, textarea, rich-text, datetime, history-number + is_required BOOLEAN DEFAULT false, + placeholder TEXT, + default_value TEXT, + validation_rules JSONB, -- правила валидации в формате JSON + options JSONB, -- опции для select полей + position INTEGER NOT NULL DEFAULT 0, -- порядок отображения + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(form_id, code) +); + +-- Таблица статусов форм +CREATE TABLE form_statuses ( + id SERIAL PRIMARY KEY, + form_id INTEGER NOT NULL REFERENCES forms(id) ON DELETE CASCADE, + name VARCHAR(100) NOT NULL, + color VARCHAR(7) NOT NULL DEFAULT '#e5e7eb', + position INTEGER NOT NULL DEFAULT 0, + is_initial BOOLEAN DEFAULT false, -- начальный статус + is_final BOOLEAN DEFAULT false, -- финальный статус + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Таблица правил переходов между статусами +CREATE TABLE status_transitions ( + id SERIAL PRIMARY KEY, + form_id INTEGER NOT NULL REFERENCES forms(id) ON DELETE CASCADE, + from_status_id INTEGER NOT NULL REFERENCES form_statuses(id), + to_status_id INTEGER NOT NULL REFERENCES form_statuses(id), + action_type VARCHAR(20) NOT NULL, -- 'approve', 'reject', 'custom' + action_name VARCHAR(100), -- название кнопки действия + conditions JSONB, -- условия для перехода + required_role VARCHAR(20), -- роль, необходимая для действия + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Таблица задач +CREATE TABLE tasks ( + id SERIAL PRIMARY KEY, + organization_id INTEGER NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + form_id INTEGER NOT NULL REFERENCES forms(id), + title VARCHAR(500) NOT NULL, + description TEXT, + current_status_id INTEGER NOT NULL REFERENCES form_statuses(id), + assigned_to INTEGER REFERENCES users(id), -- ответственный + created_by INTEGER NOT NULL REFERENCES users(id), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + completed_at TIMESTAMP, + due_date TIMESTAMP -- дедлайн +); + +-- Таблица значений полей задач +CREATE TABLE task_field_values ( + id SERIAL PRIMARY KEY, + task_id INTEGER NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, + field_id INTEGER NOT NULL REFERENCES form_fields(id), + value JSONB, -- значение поля в JSON формате + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(task_id, field_id) +); + +-- Таблица истории изменений для history-number полей +CREATE TABLE field_history ( + id SERIAL PRIMARY KEY, + task_id INTEGER NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, + field_id INTEGER NOT NULL REFERENCES form_fields(id), + old_value JSONB, + new_value JSONB, + changed_by INTEGER NOT NULL REFERENCES users(id), + changed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Индексы для производительности +CREATE INDEX idx_forms_organization ON forms(organization_id); +CREATE INDEX idx_form_fields_form ON form_fields(form_id); +CREATE INDEX idx_form_statuses_form ON form_statuses(form_id); +CREATE INDEX idx_tasks_organization ON tasks(organization_id); +CREATE INDEX idx_tasks_form ON tasks(form_id); +CREATE INDEX idx_tasks_status ON tasks(current_status_id); +CREATE INDEX idx_tasks_assigned ON tasks(assigned_to); +CREATE INDEX idx_task_field_values_task ON task_field_values(task_id); +CREATE INDEX idx_field_history_task_field ON field_history(task_id, field_id); +``` + +### 📝 Типы полей - Детальная реализация + +**Валидация для каждого типа поля (utils/fieldValidation.js):** +```javascript +const fieldValidators = { + text: (value, rules = {}) => { + if (!value && rules.required) return 'Поле обязательно для заполнения'; + if (value && rules.minLength && value.length < rules.minLength) { + return `Минимальная длина: ${rules.minLength} символов`; + } + if (value && rules.maxLength && value.length > rules.maxLength) { + return `Максимальная длина: ${rules.maxLength} символов`; + } + if (value && rules.pattern && !new RegExp(rules.pattern).test(value)) { + return rules.patternMessage || 'Неверный формат'; + } + return null; + }, + + number: (value, rules = {}) => { + if (!value && rules.required) return 'Поле обязательно для заполнения'; + if (value !== null && value !== undefined) { + const num = Number(value); + if (isNaN(num)) return 'Должно быть числом'; + if (rules.min !== undefined && num < rules.min) { + return `Минимальное значение: ${rules.min}`; + } + if (rules.max !== undefined && num > rules.max) { + return `Максимальное значение: ${rules.max}`; + } + } + return null; + }, + + date: (value, rules = {}) => { + if (!value && rules.required) return 'Поле обязательно для заполнения'; + if (value) { + const date = new Date(value); + if (isNaN(date.getTime())) return 'Неверный формат даты'; + if (rules.minDate && date < new Date(rules.minDate)) { + return `Дата не может быть раньше ${rules.minDate}`; + } + if (rules.maxDate && date > new Date(rules.maxDate)) { + return `Дата не может быть позже ${rules.maxDate}`; + } + } + return null; + }, + + datetime: (value, rules = {}) => { + return fieldValidators.date(value, rules); // Та же валидация что и для date + }, + + select: (value, rules = {}, options = []) => { + if (!value && rules.required) return 'Поле обязательно для заполнения'; + if (value && options.length > 0) { + const validOption = options.find(opt => opt.id === value); + if (!validOption) return 'Неверное значение'; + } + return null; + }, + + checkbox: (value, rules = {}) => { + if (rules.required && !value) return 'Поле должно быть отмечено'; + return null; + }, + + toggle: (value, rules = {}) => { + return fieldValidators.checkbox(value, rules); + }, + + user: (value, rules = {}) => { + if (!value && rules.required) return 'Необходимо выбрать пользователя'; + if (value && !Number.isInteger(Number(value))) { + return 'Неверный ID пользователя'; + } + return null; + }, + + file: (value, rules = {}) => { + if (!value && rules.required) return 'Необходимо загрузить файл'; + if (value && rules.maxFileSize && value.size > rules.maxFileSize) { + return `Максимальный размер файла: ${rules.maxFileSize / 1024 / 1024} МБ`; + } + if (value && rules.allowedTypes && !rules.allowedTypes.includes(value.type)) { + return `Разрешенные типы файлов: ${rules.allowedTypes.join(', ')}`; + } + return null; + }, + + textarea: (value, rules = {}) => { + return fieldValidators.text(value, rules); + }, + + 'rich-text': (value, rules = {}) => { + // Проверяем HTML контент + if (!value && rules.required) return 'Поле обязательно для заполнения'; + if (value && rules.maxLength) { + // Удаляем HTML теги для подсчета длины текста + const textContent = value.replace(/<[^>]*>/g, ''); + if (textContent.length > rules.maxLength) { + return `Максимальная длина текста: ${rules.maxLength} символов`; + } + } + return null; + }, + + 'history-number': (value, rules = {}) => { + return fieldValidators.number(value, rules); + } +}; + +const validateFieldValue = (fieldType, value, validationRules, options) => { + const validator = fieldValidators[fieldType]; + if (!validator) { + throw new Error(`Неизвестный тип поля: ${fieldType}`); + } + return validator(value, validationRules, options); +}; + +module.exports = { validateFieldValue, fieldValidators }; +``` + +### 🔧 API для форм - Детальная реализация + +**CRUD операции для форм (controllers/forms.controller.js):** +```javascript +const Form = require('../models/Form'); +const FormField = require('../models/FormField'); +const FormStatus = require('../models/FormStatus'); +const { validateFieldValue } = require('../utils/fieldValidation'); + +// Получить все формы организации +const getForms = async (req, res) => { + try { + const { page = 1, limit = 20, search } = req.query; + const offset = (page - 1) * limit; + + let whereClause = { organization_id: req.tenantId, is_active: true }; + + if (search) { + whereClause.name = { [Op.iLike]: `%${search}%` }; + } + + const { rows: forms, count } = await Form.findAndCountAll({ + where: whereClause, + include: [ + { + model: FormField, + as: 'fields', + order: [['position', 'ASC']] + }, + { + model: FormStatus, + as: 'statuses', + order: [['position', 'ASC']] + } + ], + limit: parseInt(limit), + offset: parseInt(offset), + order: [['created_at', 'DESC']] + }); + + res.json({ + success: true, + data: forms, + pagination: { + page: parseInt(page), + limit: parseInt(limit), + total: count, + pages: Math.ceil(count / limit) + } + }); + } catch (error) { + console.error('Ошибка получения форм:', error); + res.status(500).json({ success: false, error: 'Ошибка сервера' }); + } +}; + +// Создать новую форму +const createForm = async (req, res) => { + const transaction = await sequelize.transaction(); + + try { + const { name, description, fields = [], statuses = [] } = req.body; + + // Валидация входных данных + if (!name || name.trim().length === 0) { + return res.status(400).json({ + success: false, + error: 'Название формы обязательно' + }); + } + + // Создаем форму + const form = await Form.create({ + organization_id: req.tenantId, + name: name.trim(), + description: description?.trim(), + created_by: req.user.id + }, { transaction }); + + // Создаем поля формы + const createdFields = []; + for (let i = 0; i < fields.length; i++) { + const field = fields[i]; + + // Валидация поля + if (!field.name || !field.type) { + throw new Error(`Поле ${i + 1}: название и тип обязательны`); + } + + const createdField = await FormField.create({ + form_id: form.id, + name: field.name.trim(), + code: field.code || `field_${i + 1}`, + type: field.type, + is_required: field.is_required || false, + placeholder: field.placeholder, + default_value: field.default_value, + validation_rules: field.validation_rules || {}, + options: field.options || [], + position: i + }, { transaction }); + + createdFields.push(createdField); + } + + // Создаем статусы (если не указаны, создаем по умолчанию) + const defaultStatuses = statuses.length > 0 ? statuses : [ + { name: 'Создана', color: '#e5e7eb', is_initial: true }, + { name: 'В работе', color: '#bfdbfe' }, + { name: 'Завершена', color: '#d1fae5', is_final: true } + ]; + + const createdStatuses = []; + for (let i = 0; i < defaultStatuses.length; i++) { + const status = defaultStatuses[i]; + + const createdStatus = await FormStatus.create({ + form_id: form.id, + name: status.name, + color: status.color || '#e5e7eb', + position: i, + is_initial: i === 0 || status.is_initial, + is_final: i === defaultStatuses.length - 1 || status.is_final + }, { transaction }); + + createdStatuses.push(createdStatus); + } + + await transaction.commit(); + + // Возвращаем созданную форму с полями и статусами + const fullForm = await Form.findByPk(form.id, { + include: [ + { model: FormField, as: 'fields', order: [['position', 'ASC']] }, + { model: FormStatus, as: 'statuses', order: [['position', 'ASC']] } + ] + }); + + res.status(201).json({ + success: true, + data: fullForm, + message: 'Форма успешно создана' + }); + + } catch (error) { + await transaction.rollback(); + console.error('Ошибка создания формы:', error); + res.status(400).json({ + success: false, + error: error.message || 'Ошибка создания формы' + }); + } +}; + +// Обновить форму +const updateForm = async (req, res) => { + const transaction = await sequelize.transaction(); + + try { + const { id } = req.params; + const { name, description, fields, statuses } = req.body; + + // Находим форму + const form = await Form.findOne({ + where: { id, organization_id: req.tenantId } + }); + + if (!form) { + return res.status(404).json({ + success: false, + error: 'Форма не найдена' + }); + } + + // Обновляем основную информацию формы + if (name) form.name = name.trim(); + if (description !== undefined) form.description = description?.trim(); + form.updated_at = new Date(); + + await form.save({ transaction }); + + // Обновляем поля, если переданы + if (fields && Array.isArray(fields)) { + // Удаляем старые поля + await FormField.destroy({ + where: { form_id: form.id }, + transaction + }); + + // Создаем новые поля + for (let i = 0; i < fields.length; i++) { + const field = fields[i]; + await FormField.create({ + form_id: form.id, + name: field.name.trim(), + code: field.code || `field_${i + 1}`, + type: field.type, + is_required: field.is_required || false, + placeholder: field.placeholder, + default_value: field.default_value, + validation_rules: field.validation_rules || {}, + options: field.options || [], + position: i + }, { transaction }); + } + } + + // Обновляем статусы, если переданы + if (statuses && Array.isArray(statuses)) { + // Удаляем старые статусы + await FormStatus.destroy({ + where: { form_id: form.id }, + transaction + }); + + // Создаем новые статусы + for (let i = 0; i < statuses.length; i++) { + const status = statuses[i]; + await FormStatus.create({ + form_id: form.id, + name: status.name, + color: status.color || '#e5e7eb', + position: i, + is_initial: i === 0 || status.is_initial, + is_final: i === statuses.length - 1 || status.is_final + }, { transaction }); + } + } + + await transaction.commit(); + + // Возвращаем обновленную форму + const updatedForm = await Form.findByPk(form.id, { + include: [ + { model: FormField, as: 'fields', order: [['position', 'ASC']] }, + { model: FormStatus, as: 'statuses', order: [['position', 'ASC']] } + ] + }); + + res.json({ + success: true, + data: updatedForm, + message: 'Форма успешно обновлена' + }); + + } catch (error) { + await transaction.rollback(); + console.error('Ошибка обновления формы:', error); + res.status(400).json({ + success: false, + error: error.message || 'Ошибка обновления формы' + }); + } +}; + +module.exports = { + getForms, + createForm, + updateForm, + // ... другие методы +}; +``` + +### ✅ Критерии завершения Этапа 2 + +**Функциональные требования:** +1. ✅ Создание форм с различными типами полей +2. ✅ Валидация полей согласно их типам +3. ✅ Настройка статусов форм с цветами +4. ✅ Правила переходов между статусами +5. ✅ Создание задач на основе форм +6. ✅ Назначение ответственных за задачи +7. ✅ Заполнение и редактирование полей задач +8. ✅ История изменений для history-number полей +9. ✅ Пагинация для всех списков +10. ✅ Фильтрация и поиск форм/задач + +**Продолжение следует в Этапе 3...** diff --git a/attached_assets/image_1758006135951.png b/attached_assets/image_1758006135951.png new file mode 100644 index 0000000..0ab0491 Binary files /dev/null and b/attached_assets/image_1758006135951.png differ diff --git a/attached_assets/image_1758009667119.png b/attached_assets/image_1758009667119.png new file mode 100644 index 0000000..78e9a48 Binary files /dev/null and b/attached_assets/image_1758009667119.png differ diff --git a/attached_assets/image_1758017517992.png b/attached_assets/image_1758017517992.png new file mode 100644 index 0000000..be4838b Binary files /dev/null and b/attached_assets/image_1758017517992.png differ diff --git a/attached_assets/image_1758017570911.png b/attached_assets/image_1758017570911.png new file mode 100644 index 0000000..1477002 Binary files /dev/null and b/attached_assets/image_1758017570911.png differ diff --git a/attached_assets/image_1758021659230.png b/attached_assets/image_1758021659230.png new file mode 100644 index 0000000..76e36d7 Binary files /dev/null and b/attached_assets/image_1758021659230.png differ diff --git a/attached_assets/image_1758026138825.png b/attached_assets/image_1758026138825.png new file mode 100644 index 0000000..70a7a7e Binary files /dev/null and b/attached_assets/image_1758026138825.png differ diff --git a/attached_assets/image_1758042689913.png b/attached_assets/image_1758042689913.png new file mode 100644 index 0000000..ab99a0b Binary files /dev/null and b/attached_assets/image_1758042689913.png differ diff --git a/attached_assets/image_1758045638960.png b/attached_assets/image_1758045638960.png new file mode 100644 index 0000000..ab0a000 Binary files /dev/null and b/attached_assets/image_1758045638960.png differ diff --git a/attached_assets/image_1758045704829.png b/attached_assets/image_1758045704829.png new file mode 100644 index 0000000..acfb98a Binary files /dev/null and b/attached_assets/image_1758045704829.png differ diff --git a/attached_assets/image_1758045938600.png b/attached_assets/image_1758045938600.png new file mode 100644 index 0000000..6cdb022 Binary files /dev/null and b/attached_assets/image_1758045938600.png differ diff --git a/attached_assets/image_1758046292803.png b/attached_assets/image_1758046292803.png new file mode 100644 index 0000000..0dc4c69 Binary files /dev/null and b/attached_assets/image_1758046292803.png differ diff --git a/attached_assets/image_1758051618053.png b/attached_assets/image_1758051618053.png new file mode 100644 index 0000000..8fc5259 Binary files /dev/null and b/attached_assets/image_1758051618053.png differ diff --git a/attached_assets/image_1758051652835.png b/attached_assets/image_1758051652835.png new file mode 100644 index 0000000..405bae7 Binary files /dev/null and b/attached_assets/image_1758051652835.png differ diff --git a/attached_assets/image_1758051721522.png b/attached_assets/image_1758051721522.png new file mode 100644 index 0000000..e1c4fe3 Binary files /dev/null and b/attached_assets/image_1758051721522.png differ diff --git a/attached_assets/image_1758051838748.png b/attached_assets/image_1758051838748.png new file mode 100644 index 0000000..34d7f85 Binary files /dev/null and b/attached_assets/image_1758051838748.png differ diff --git a/attached_assets/image_1758051888537.png b/attached_assets/image_1758051888537.png new file mode 100644 index 0000000..c9ddaff Binary files /dev/null and b/attached_assets/image_1758051888537.png differ diff --git a/attached_assets/image_1758055935161.png b/attached_assets/image_1758055935161.png new file mode 100644 index 0000000..aa25e5b Binary files /dev/null and b/attached_assets/image_1758055935161.png differ diff --git a/attached_assets/image_1758055993698.png b/attached_assets/image_1758055993698.png new file mode 100644 index 0000000..a0b1ccd Binary files /dev/null and b/attached_assets/image_1758055993698.png differ diff --git a/attached_assets/image_1758056595609.png b/attached_assets/image_1758056595609.png new file mode 100644 index 0000000..71b4b68 Binary files /dev/null and b/attached_assets/image_1758056595609.png differ diff --git a/attached_assets/image_1758056656964.png b/attached_assets/image_1758056656964.png new file mode 100644 index 0000000..2ff0ee8 Binary files /dev/null and b/attached_assets/image_1758056656964.png differ diff --git a/attached_assets/image_1758060339604.png b/attached_assets/image_1758060339604.png new file mode 100644 index 0000000..a4b3104 Binary files /dev/null and b/attached_assets/image_1758060339604.png differ diff --git a/attached_assets/image_1758060464999.png b/attached_assets/image_1758060464999.png new file mode 100644 index 0000000..3f5049b Binary files /dev/null and b/attached_assets/image_1758060464999.png differ diff --git a/attached_assets/image_1758094961493.png b/attached_assets/image_1758094961493.png new file mode 100644 index 0000000..4012dc9 Binary files /dev/null and b/attached_assets/image_1758094961493.png differ diff --git a/attached_assets/image_1758097316062.png b/attached_assets/image_1758097316062.png new file mode 100644 index 0000000..6ac3ee2 Binary files /dev/null and b/attached_assets/image_1758097316062.png differ diff --git a/attached_assets/image_1758097487033.png b/attached_assets/image_1758097487033.png new file mode 100644 index 0000000..0b7fcd6 Binary files /dev/null and b/attached_assets/image_1758097487033.png differ diff --git a/attached_assets/image_1758104497005.png b/attached_assets/image_1758104497005.png new file mode 100644 index 0000000..f079c71 Binary files /dev/null and b/attached_assets/image_1758104497005.png differ diff --git a/attached_assets/image_1758104522615.png b/attached_assets/image_1758104522615.png new file mode 100644 index 0000000..ee1f0dd Binary files /dev/null and b/attached_assets/image_1758104522615.png differ diff --git a/attached_assets/image_1758104561446.png b/attached_assets/image_1758104561446.png new file mode 100644 index 0000000..479765d Binary files /dev/null and b/attached_assets/image_1758104561446.png differ diff --git a/attached_assets/image_1758113228355.png b/attached_assets/image_1758113228355.png new file mode 100644 index 0000000..1a53a96 Binary files /dev/null and b/attached_assets/image_1758113228355.png differ diff --git a/attached_assets/image_1758113427325.png b/attached_assets/image_1758113427325.png new file mode 100644 index 0000000..6264931 Binary files /dev/null and b/attached_assets/image_1758113427325.png differ diff --git a/attached_assets/image_1758114654033.png b/attached_assets/image_1758114654033.png new file mode 100644 index 0000000..8917b72 Binary files /dev/null and b/attached_assets/image_1758114654033.png differ diff --git a/attached_assets/image_1758115310557.png b/attached_assets/image_1758115310557.png new file mode 100644 index 0000000..10dc2a7 Binary files /dev/null and b/attached_assets/image_1758115310557.png differ diff --git a/attached_assets/image_1758128362968.png b/attached_assets/image_1758128362968.png new file mode 100644 index 0000000..3a6cf8f Binary files /dev/null and b/attached_assets/image_1758128362968.png differ diff --git a/attached_assets/image_1758130168543.png b/attached_assets/image_1758130168543.png new file mode 100644 index 0000000..05232aa Binary files /dev/null and b/attached_assets/image_1758130168543.png differ diff --git a/attached_assets/image_1758130864677.png b/attached_assets/image_1758130864677.png new file mode 100644 index 0000000..7a81184 Binary files /dev/null and b/attached_assets/image_1758130864677.png differ diff --git a/attached_assets/image_1758132120516.png b/attached_assets/image_1758132120516.png new file mode 100644 index 0000000..8e668b0 Binary files /dev/null and b/attached_assets/image_1758132120516.png differ diff --git a/attached_assets/image_1758135051029.png b/attached_assets/image_1758135051029.png new file mode 100644 index 0000000..a9cc097 Binary files /dev/null and b/attached_assets/image_1758135051029.png differ diff --git a/attached_assets/image_1758135070763.png b/attached_assets/image_1758135070763.png new file mode 100644 index 0000000..3941c96 Binary files /dev/null and b/attached_assets/image_1758135070763.png differ diff --git a/attached_assets/image_1758139281387.png b/attached_assets/image_1758139281387.png new file mode 100644 index 0000000..74a026f Binary files /dev/null and b/attached_assets/image_1758139281387.png differ diff --git a/attached_assets/image_1758139690656.png b/attached_assets/image_1758139690656.png new file mode 100644 index 0000000..0758f26 Binary files /dev/null and b/attached_assets/image_1758139690656.png differ diff --git a/attached_assets/image_1758139814483.png b/attached_assets/image_1758139814483.png new file mode 100644 index 0000000..60afa02 Binary files /dev/null and b/attached_assets/image_1758139814483.png differ diff --git a/attached_assets/image_1758140061120.png b/attached_assets/image_1758140061120.png new file mode 100644 index 0000000..9568fb9 Binary files /dev/null and b/attached_assets/image_1758140061120.png differ diff --git a/attached_assets/image_1758140138328.png b/attached_assets/image_1758140138328.png new file mode 100644 index 0000000..764cb53 Binary files /dev/null and b/attached_assets/image_1758140138328.png differ diff --git a/attached_assets/image_1758146720691.png b/attached_assets/image_1758146720691.png new file mode 100644 index 0000000..bbe2873 Binary files /dev/null and b/attached_assets/image_1758146720691.png differ diff --git a/attached_assets/image_1758147014575.png b/attached_assets/image_1758147014575.png new file mode 100644 index 0000000..9b099c9 Binary files /dev/null and b/attached_assets/image_1758147014575.png differ diff --git a/attached_assets/image_1758147414364.png b/attached_assets/image_1758147414364.png new file mode 100644 index 0000000..34529ab Binary files /dev/null and b/attached_assets/image_1758147414364.png differ diff --git a/attached_assets/image_1758148032195.png b/attached_assets/image_1758148032195.png new file mode 100644 index 0000000..cde7ea9 Binary files /dev/null and b/attached_assets/image_1758148032195.png differ diff --git a/attached_assets/image_1758216462840.png b/attached_assets/image_1758216462840.png new file mode 100644 index 0000000..2cee0af Binary files /dev/null and b/attached_assets/image_1758216462840.png differ diff --git a/attached_assets/image_1758216496749.png b/attached_assets/image_1758216496749.png new file mode 100644 index 0000000..49f98ed Binary files /dev/null and b/attached_assets/image_1758216496749.png differ diff --git a/attached_assets/image_1758217793501.png b/attached_assets/image_1758217793501.png new file mode 100644 index 0000000..24ecbe7 Binary files /dev/null and b/attached_assets/image_1758217793501.png differ diff --git a/attached_assets/image_1758218311090.png b/attached_assets/image_1758218311090.png new file mode 100644 index 0000000..53b5876 Binary files /dev/null and b/attached_assets/image_1758218311090.png differ diff --git a/attached_assets/image_1758221156894.png b/attached_assets/image_1758221156894.png new file mode 100644 index 0000000..85bcdf2 Binary files /dev/null and b/attached_assets/image_1758221156894.png differ diff --git a/attached_assets/image_1758221468247.png b/attached_assets/image_1758221468247.png new file mode 100644 index 0000000..40ec64a Binary files /dev/null and b/attached_assets/image_1758221468247.png differ diff --git a/attached_assets/image_1758221573698.png b/attached_assets/image_1758221573698.png new file mode 100644 index 0000000..9973c8c Binary files /dev/null and b/attached_assets/image_1758221573698.png differ diff --git a/attached_assets/image_1758221818140.png b/attached_assets/image_1758221818140.png new file mode 100644 index 0000000..d7c9e0c Binary files /dev/null and b/attached_assets/image_1758221818140.png differ diff --git a/attached_assets/image_1758226445432.png b/attached_assets/image_1758226445432.png new file mode 100644 index 0000000..67e554f Binary files /dev/null and b/attached_assets/image_1758226445432.png differ diff --git a/attached_assets/image_1758227494950.png b/attached_assets/image_1758227494950.png new file mode 100644 index 0000000..8215831 Binary files /dev/null and b/attached_assets/image_1758227494950.png differ diff --git a/attached_assets/image_1758227866465.png b/attached_assets/image_1758227866465.png new file mode 100644 index 0000000..2ac8f90 Binary files /dev/null and b/attached_assets/image_1758227866465.png differ diff --git a/attached_assets/image_1758228127217.png b/attached_assets/image_1758228127217.png new file mode 100644 index 0000000..f46cf3a Binary files /dev/null and b/attached_assets/image_1758228127217.png differ diff --git a/attached_assets/image_1758228382549.png b/attached_assets/image_1758228382549.png new file mode 100644 index 0000000..3234229 Binary files /dev/null and b/attached_assets/image_1758228382549.png differ diff --git a/attached_assets/image_1758229694485.png b/attached_assets/image_1758229694485.png new file mode 100644 index 0000000..0faf3b4 Binary files /dev/null and b/attached_assets/image_1758229694485.png differ diff --git a/attached_assets/image_1758231434551.png b/attached_assets/image_1758231434551.png new file mode 100644 index 0000000..0bd0253 Binary files /dev/null and b/attached_assets/image_1758231434551.png differ diff --git a/attached_assets/image_1758233614914.png b/attached_assets/image_1758233614914.png new file mode 100644 index 0000000..19cd9e1 Binary files /dev/null and b/attached_assets/image_1758233614914.png differ diff --git a/attached_assets/image_1758233817408.png b/attached_assets/image_1758233817408.png new file mode 100644 index 0000000..1e02bc9 Binary files /dev/null and b/attached_assets/image_1758233817408.png differ diff --git a/attached_assets/image_1758234294263.png b/attached_assets/image_1758234294263.png new file mode 100644 index 0000000..13e73db Binary files /dev/null and b/attached_assets/image_1758234294263.png differ diff --git a/attached_assets/image_1758270443452.png b/attached_assets/image_1758270443452.png new file mode 100644 index 0000000..69ac398 Binary files /dev/null and b/attached_assets/image_1758270443452.png differ diff --git a/attached_assets/image_1758272052635.png b/attached_assets/image_1758272052635.png new file mode 100644 index 0000000..4722f16 Binary files /dev/null and b/attached_assets/image_1758272052635.png differ diff --git a/attached_assets/image_1758306467616.png b/attached_assets/image_1758306467616.png new file mode 100644 index 0000000..25c815e Binary files /dev/null and b/attached_assets/image_1758306467616.png differ diff --git a/attached_assets/image_1758306504999.png b/attached_assets/image_1758306504999.png new file mode 100644 index 0000000..fc2f670 Binary files /dev/null and b/attached_assets/image_1758306504999.png differ diff --git a/attached_assets/image_1758314676188.png b/attached_assets/image_1758314676188.png new file mode 100644 index 0000000..7cf2664 Binary files /dev/null and b/attached_assets/image_1758314676188.png differ diff --git a/attached_assets/image_1758525978350.png b/attached_assets/image_1758525978350.png new file mode 100644 index 0000000..1bda26e Binary files /dev/null and b/attached_assets/image_1758525978350.png differ diff --git a/attached_assets/image_1758526049230.png b/attached_assets/image_1758526049230.png new file mode 100644 index 0000000..c21a382 Binary files /dev/null and b/attached_assets/image_1758526049230.png differ diff --git a/attached_assets/image_1758529034961.png b/attached_assets/image_1758529034961.png new file mode 100644 index 0000000..ade4c52 Binary files /dev/null and b/attached_assets/image_1758529034961.png differ diff --git a/attached_assets/image_1758529251811.png b/attached_assets/image_1758529251811.png new file mode 100644 index 0000000..834bd72 Binary files /dev/null and b/attached_assets/image_1758529251811.png differ diff --git a/attached_assets/image_1758529464876.png b/attached_assets/image_1758529464876.png new file mode 100644 index 0000000..ac44a9a Binary files /dev/null and b/attached_assets/image_1758529464876.png differ diff --git a/attached_assets/image_1758530157627.png b/attached_assets/image_1758530157627.png new file mode 100644 index 0000000..6f35a3f Binary files /dev/null and b/attached_assets/image_1758530157627.png differ diff --git a/attached_assets/image_1758530442601.png b/attached_assets/image_1758530442601.png new file mode 100644 index 0000000..a03e3da Binary files /dev/null and b/attached_assets/image_1758530442601.png differ diff --git a/attached_assets/image_1758546565545.png b/attached_assets/image_1758546565545.png new file mode 100644 index 0000000..19a0ae6 Binary files /dev/null and b/attached_assets/image_1758546565545.png differ diff --git a/attached_assets/image_1758546960986.png b/attached_assets/image_1758546960986.png new file mode 100644 index 0000000..1c7e323 Binary files /dev/null and b/attached_assets/image_1758546960986.png differ diff --git a/attached_assets/image_1758547680354.png b/attached_assets/image_1758547680354.png new file mode 100644 index 0000000..fbffa8f Binary files /dev/null and b/attached_assets/image_1758547680354.png differ diff --git a/attached_assets/image_1758547840639.png b/attached_assets/image_1758547840639.png new file mode 100644 index 0000000..9b238bc Binary files /dev/null and b/attached_assets/image_1758547840639.png differ diff --git a/attached_assets/image_1758548001050.png b/attached_assets/image_1758548001050.png new file mode 100644 index 0000000..be6977e Binary files /dev/null and b/attached_assets/image_1758548001050.png differ diff --git a/attached_assets/image_1758549581762.png b/attached_assets/image_1758549581762.png new file mode 100644 index 0000000..03f850c Binary files /dev/null and b/attached_assets/image_1758549581762.png differ diff --git a/attached_assets/image_1758549734840.png b/attached_assets/image_1758549734840.png new file mode 100644 index 0000000..afcca45 Binary files /dev/null and b/attached_assets/image_1758549734840.png differ diff --git a/attached_assets/image_1758549835774.png b/attached_assets/image_1758549835774.png new file mode 100644 index 0000000..28b565f Binary files /dev/null and b/attached_assets/image_1758549835774.png differ diff --git a/attached_assets/image_1758608449408.png b/attached_assets/image_1758608449408.png new file mode 100644 index 0000000..4829197 Binary files /dev/null and b/attached_assets/image_1758608449408.png differ diff --git a/attached_assets/image_1758615008199.png b/attached_assets/image_1758615008199.png new file mode 100644 index 0000000..8ad8b02 Binary files /dev/null and b/attached_assets/image_1758615008199.png differ diff --git a/attached_assets/image_1758615493281.png b/attached_assets/image_1758615493281.png new file mode 100644 index 0000000..9ed643a Binary files /dev/null and b/attached_assets/image_1758615493281.png differ diff --git a/attached_assets/image_1758714869893.png b/attached_assets/image_1758714869893.png new file mode 100644 index 0000000..05420b5 Binary files /dev/null and b/attached_assets/image_1758714869893.png differ diff --git a/attached_assets/image_1758716063495.png b/attached_assets/image_1758716063495.png new file mode 100644 index 0000000..a2afaf5 Binary files /dev/null and b/attached_assets/image_1758716063495.png differ diff --git a/attached_assets/image_1758716113355.png b/attached_assets/image_1758716113355.png new file mode 100644 index 0000000..a8234c4 Binary files /dev/null and b/attached_assets/image_1758716113355.png differ diff --git a/attached_assets/image_1758718343023.png b/attached_assets/image_1758718343023.png new file mode 100644 index 0000000..b42e0bc Binary files /dev/null and b/attached_assets/image_1758718343023.png differ diff --git a/attached_assets/image_1758718483934.png b/attached_assets/image_1758718483934.png new file mode 100644 index 0000000..5346977 Binary files /dev/null and b/attached_assets/image_1758718483934.png differ diff --git a/attached_assets/image_1758735595554.png b/attached_assets/image_1758735595554.png new file mode 100644 index 0000000..d7ce069 Binary files /dev/null and b/attached_assets/image_1758735595554.png differ diff --git a/attached_assets/image_1758738049793.png b/attached_assets/image_1758738049793.png new file mode 100644 index 0000000..6380dd4 Binary files /dev/null and b/attached_assets/image_1758738049793.png differ diff --git a/attached_assets/image_1758807682229.png b/attached_assets/image_1758807682229.png new file mode 100644 index 0000000..214188d Binary files /dev/null and b/attached_assets/image_1758807682229.png differ diff --git a/attached_assets/image_1758808655034.png b/attached_assets/image_1758808655034.png new file mode 100644 index 0000000..4b1ded6 Binary files /dev/null and b/attached_assets/image_1758808655034.png differ diff --git a/attached_assets/image_1759174570768.png b/attached_assets/image_1759174570768.png new file mode 100644 index 0000000..ab98c74 Binary files /dev/null and b/attached_assets/image_1759174570768.png differ diff --git a/attached_assets/image_1759177385808.png b/attached_assets/image_1759177385808.png new file mode 100644 index 0000000..78d6f28 Binary files /dev/null and b/attached_assets/image_1759177385808.png differ diff --git a/attached_assets/image_1759177501031.png b/attached_assets/image_1759177501031.png new file mode 100644 index 0000000..4a0c1ae Binary files /dev/null and b/attached_assets/image_1759177501031.png differ diff --git a/attached_assets/image_1759177573427.png b/attached_assets/image_1759177573427.png new file mode 100644 index 0000000..cf724f1 Binary files /dev/null and b/attached_assets/image_1759177573427.png differ diff --git a/attached_assets/image_1759177640051.png b/attached_assets/image_1759177640051.png new file mode 100644 index 0000000..2b61994 Binary files /dev/null and b/attached_assets/image_1759177640051.png differ diff --git a/attached_assets/image_1759178738074.png b/attached_assets/image_1759178738074.png new file mode 100644 index 0000000..7d957d9 Binary files /dev/null and b/attached_assets/image_1759178738074.png differ diff --git a/attached_assets/image_1759178835353.png b/attached_assets/image_1759178835353.png new file mode 100644 index 0000000..e856aa3 Binary files /dev/null and b/attached_assets/image_1759178835353.png differ diff --git a/attached_assets/image_1759178894293.png b/attached_assets/image_1759178894293.png new file mode 100644 index 0000000..fef98a8 Binary files /dev/null and b/attached_assets/image_1759178894293.png differ diff --git a/attached_assets/image_1759181383472.png b/attached_assets/image_1759181383472.png new file mode 100644 index 0000000..a427074 Binary files /dev/null and b/attached_assets/image_1759181383472.png differ diff --git a/attached_assets/image_1759181451149.png b/attached_assets/image_1759181451149.png new file mode 100644 index 0000000..371ec13 Binary files /dev/null and b/attached_assets/image_1759181451149.png differ diff --git a/attached_assets/image_1763639593463.png b/attached_assets/image_1763639593463.png new file mode 100644 index 0000000..186b297 Binary files /dev/null and b/attached_assets/image_1763639593463.png differ diff --git a/attached_assets/image_1763639945296.png b/attached_assets/image_1763639945296.png new file mode 100644 index 0000000..fe79372 Binary files /dev/null and b/attached_assets/image_1763639945296.png differ diff --git a/attached_assets/image_1763640443588.png b/attached_assets/image_1763640443588.png new file mode 100644 index 0000000..3467001 Binary files /dev/null and b/attached_assets/image_1763640443588.png differ diff --git a/attached_assets/image_1763645550618.png b/attached_assets/image_1763645550618.png new file mode 100644 index 0000000..e75399e Binary files /dev/null and b/attached_assets/image_1763645550618.png differ diff --git a/attached_assets/image_1763705238433.png b/attached_assets/image_1763705238433.png new file mode 100644 index 0000000..e187f33 Binary files /dev/null and b/attached_assets/image_1763705238433.png differ diff --git a/attached_assets/image_1763705647344.png b/attached_assets/image_1763705647344.png new file mode 100644 index 0000000..d38b550 Binary files /dev/null and b/attached_assets/image_1763705647344.png differ diff --git a/attached_assets/image_1763705811111.png b/attached_assets/image_1763705811111.png new file mode 100644 index 0000000..f5a5d59 Binary files /dev/null and b/attached_assets/image_1763705811111.png differ diff --git a/attached_assets/image_1764616667701.png b/attached_assets/image_1764616667701.png new file mode 100644 index 0000000..6174968 Binary files /dev/null and b/attached_assets/image_1764616667701.png differ diff --git a/attached_assets/image_1764617861865.png b/attached_assets/image_1764617861865.png new file mode 100644 index 0000000..7b004bf Binary files /dev/null and b/attached_assets/image_1764617861865.png differ diff --git a/attached_assets/image_1764618346045.png b/attached_assets/image_1764618346045.png new file mode 100644 index 0000000..e7f8277 Binary files /dev/null and b/attached_assets/image_1764618346045.png differ diff --git a/attached_assets/image_1764618539865.png b/attached_assets/image_1764618539865.png new file mode 100644 index 0000000..e234d77 Binary files /dev/null and b/attached_assets/image_1764618539865.png differ diff --git a/attached_assets/image_1764619617009.png b/attached_assets/image_1764619617009.png new file mode 100644 index 0000000..9a86ce3 Binary files /dev/null and b/attached_assets/image_1764619617009.png differ diff --git a/attached_assets/image_1764622054992.png b/attached_assets/image_1764622054992.png new file mode 100644 index 0000000..c3c4c35 Binary files /dev/null and b/attached_assets/image_1764622054992.png differ diff --git a/attached_assets/image_1764622897754.png b/attached_assets/image_1764622897754.png new file mode 100644 index 0000000..b5bdda3 Binary files /dev/null and b/attached_assets/image_1764622897754.png differ diff --git a/attached_assets/image_1764623182497.png b/attached_assets/image_1764623182497.png new file mode 100644 index 0000000..77c5014 Binary files /dev/null and b/attached_assets/image_1764623182497.png differ diff --git a/attached_assets/image_1764624158857.png b/attached_assets/image_1764624158857.png new file mode 100644 index 0000000..b0f0e30 Binary files /dev/null and b/attached_assets/image_1764624158857.png differ diff --git a/attached_assets/image_1764625654189.png b/attached_assets/image_1764625654189.png new file mode 100644 index 0000000..57dba57 Binary files /dev/null and b/attached_assets/image_1764625654189.png differ diff --git a/attached_assets/image_1764626473169.png b/attached_assets/image_1764626473169.png new file mode 100644 index 0000000..7b94e2d Binary files /dev/null and b/attached_assets/image_1764626473169.png differ diff --git a/attached_assets/image_1764626668716.png b/attached_assets/image_1764626668716.png new file mode 100644 index 0000000..e86c510 Binary files /dev/null and b/attached_assets/image_1764626668716.png differ diff --git a/attached_assets/image_1764627064946.png b/attached_assets/image_1764627064946.png new file mode 100644 index 0000000..3b716ef Binary files /dev/null and b/attached_assets/image_1764627064946.png differ diff --git a/attached_assets/image_1764627645582.png b/attached_assets/image_1764627645582.png new file mode 100644 index 0000000..859a4c7 Binary files /dev/null and b/attached_assets/image_1764627645582.png differ diff --git a/attached_assets/image_1764627727297.png b/attached_assets/image_1764627727297.png new file mode 100644 index 0000000..7bcded3 Binary files /dev/null and b/attached_assets/image_1764627727297.png differ diff --git a/attached_assets/image_1764630199168.png b/attached_assets/image_1764630199168.png new file mode 100644 index 0000000..40a5ba7 Binary files /dev/null and b/attached_assets/image_1764630199168.png differ diff --git a/attached_assets/image_1764631211355.png b/attached_assets/image_1764631211355.png new file mode 100644 index 0000000..c1d6527 Binary files /dev/null and b/attached_assets/image_1764631211355.png differ diff --git a/attached_assets/image_1764631335705.png b/attached_assets/image_1764631335705.png new file mode 100644 index 0000000..e53b6da Binary files /dev/null and b/attached_assets/image_1764631335705.png differ diff --git a/attached_assets/image_1764631454331.png b/attached_assets/image_1764631454331.png new file mode 100644 index 0000000..eb759ca Binary files /dev/null and b/attached_assets/image_1764631454331.png differ diff --git a/attached_assets/image_1765281760905.png b/attached_assets/image_1765281760905.png new file mode 100644 index 0000000..a68e1eb Binary files /dev/null and b/attached_assets/image_1765281760905.png differ diff --git a/attached_assets/image_1765281764831.png b/attached_assets/image_1765281764831.png new file mode 100644 index 0000000..a68e1eb Binary files /dev/null and b/attached_assets/image_1765281764831.png differ diff --git a/attached_assets/image_1765281953304.png b/attached_assets/image_1765281953304.png new file mode 100644 index 0000000..9b883d1 Binary files /dev/null and b/attached_assets/image_1765281953304.png differ diff --git a/attached_assets/image_1765303758808.png b/attached_assets/image_1765303758808.png new file mode 100644 index 0000000..b0fe26f Binary files /dev/null and b/attached_assets/image_1765303758808.png differ diff --git a/attached_assets/image_1765307331259.png b/attached_assets/image_1765307331259.png new file mode 100644 index 0000000..868c0ef Binary files /dev/null and b/attached_assets/image_1765307331259.png differ diff --git a/attached_assets/image_1765307362246.png b/attached_assets/image_1765307362246.png new file mode 100644 index 0000000..d00674c Binary files /dev/null and b/attached_assets/image_1765307362246.png differ diff --git a/attached_assets/image_1765311170187.png b/attached_assets/image_1765311170187.png new file mode 100644 index 0000000..ac7bb39 Binary files /dev/null and b/attached_assets/image_1765311170187.png differ diff --git a/attached_assets/image_1765311523815.png b/attached_assets/image_1765311523815.png new file mode 100644 index 0000000..d391c4f Binary files /dev/null and b/attached_assets/image_1765311523815.png differ diff --git a/attached_assets/image_1765311602393.png b/attached_assets/image_1765311602393.png new file mode 100644 index 0000000..a15ce59 Binary files /dev/null and b/attached_assets/image_1765311602393.png differ diff --git a/attached_assets/image_1765313830949.png b/attached_assets/image_1765313830949.png new file mode 100644 index 0000000..fa32ee2 Binary files /dev/null and b/attached_assets/image_1765313830949.png differ diff --git a/attached_assets/image_1765313843224.png b/attached_assets/image_1765313843224.png new file mode 100644 index 0000000..af6864c Binary files /dev/null and b/attached_assets/image_1765313843224.png differ diff --git a/attached_assets/image_1765313858552.png b/attached_assets/image_1765313858552.png new file mode 100644 index 0000000..b4d1aa8 Binary files /dev/null and b/attached_assets/image_1765313858552.png differ diff --git a/attached_assets/image_1765909289053.png b/attached_assets/image_1765909289053.png new file mode 100644 index 0000000..8266b7c Binary files /dev/null and b/attached_assets/image_1765909289053.png differ diff --git a/attached_assets/image_1765909808690.png b/attached_assets/image_1765909808690.png new file mode 100644 index 0000000..ef55f07 Binary files /dev/null and b/attached_assets/image_1765909808690.png differ diff --git a/attached_assets/image_1765909994568.png b/attached_assets/image_1765909994568.png new file mode 100644 index 0000000..e2355e7 Binary files /dev/null and b/attached_assets/image_1765909994568.png differ diff --git a/attached_assets/image_1765910746792.png b/attached_assets/image_1765910746792.png new file mode 100644 index 0000000..2476f3d Binary files /dev/null and b/attached_assets/image_1765910746792.png differ diff --git a/attached_assets/image_1765910895674.png b/attached_assets/image_1765910895674.png new file mode 100644 index 0000000..a072ee3 Binary files /dev/null and b/attached_assets/image_1765910895674.png differ diff --git a/attached_assets/image_1765911950040.png b/attached_assets/image_1765911950040.png new file mode 100644 index 0000000..7523b95 Binary files /dev/null and b/attached_assets/image_1765911950040.png differ diff --git a/attached_assets/image_1765912404110.png b/attached_assets/image_1765912404110.png new file mode 100644 index 0000000..8173cbf Binary files /dev/null and b/attached_assets/image_1765912404110.png differ diff --git a/attached_assets/image_1765914076713.png b/attached_assets/image_1765914076713.png new file mode 100644 index 0000000..31df0f8 Binary files /dev/null and b/attached_assets/image_1765914076713.png differ diff --git a/attached_assets/image_1765914146259.png b/attached_assets/image_1765914146259.png new file mode 100644 index 0000000..24413ec Binary files /dev/null and b/attached_assets/image_1765914146259.png differ diff --git a/attached_assets/image_1765914454459.png b/attached_assets/image_1765914454459.png new file mode 100644 index 0000000..f0694aa Binary files /dev/null and b/attached_assets/image_1765914454459.png differ diff --git a/attached_assets/image_1765914995699.png b/attached_assets/image_1765914995699.png new file mode 100644 index 0000000..4003a86 Binary files /dev/null and b/attached_assets/image_1765914995699.png differ diff --git a/attached_assets/image_1765916555884.png b/attached_assets/image_1765916555884.png new file mode 100644 index 0000000..e88fc67 Binary files /dev/null and b/attached_assets/image_1765916555884.png differ diff --git a/attached_assets/image_1765916624114.png b/attached_assets/image_1765916624114.png new file mode 100644 index 0000000..0feafec Binary files /dev/null and b/attached_assets/image_1765916624114.png differ diff --git a/attached_assets/image_1765916822842.png b/attached_assets/image_1765916822842.png new file mode 100644 index 0000000..d2b55eb Binary files /dev/null and b/attached_assets/image_1765916822842.png differ diff --git a/attached_assets/image_1765916932381.png b/attached_assets/image_1765916932381.png new file mode 100644 index 0000000..837d056 Binary files /dev/null and b/attached_assets/image_1765916932381.png differ diff --git a/attached_assets/image_1765959306567.png b/attached_assets/image_1765959306567.png new file mode 100644 index 0000000..d5d7d4d Binary files /dev/null and b/attached_assets/image_1765959306567.png differ diff --git a/attached_assets/image_1765971051516.png b/attached_assets/image_1765971051516.png new file mode 100644 index 0000000..8e1091f Binary files /dev/null and b/attached_assets/image_1765971051516.png differ diff --git a/attached_assets/image_1765972845710.png b/attached_assets/image_1765972845710.png new file mode 100644 index 0000000..1d0a499 Binary files /dev/null and b/attached_assets/image_1765972845710.png differ diff --git a/attached_assets/image_1765974964700.png b/attached_assets/image_1765974964700.png new file mode 100644 index 0000000..0cc53e4 Binary files /dev/null and b/attached_assets/image_1765974964700.png differ diff --git a/attached_assets/image_1765978480708.png b/attached_assets/image_1765978480708.png new file mode 100644 index 0000000..2b19e2a Binary files /dev/null and b/attached_assets/image_1765978480708.png differ diff --git a/attached_assets/image_1766079383030.png b/attached_assets/image_1766079383030.png new file mode 100644 index 0000000..6ca7d4c Binary files /dev/null and b/attached_assets/image_1766079383030.png differ diff --git a/attached_assets/image_1766079854215.png b/attached_assets/image_1766079854215.png new file mode 100644 index 0000000..d06e9bc Binary files /dev/null and b/attached_assets/image_1766079854215.png differ diff --git a/attached_assets/image_1766080135844.png b/attached_assets/image_1766080135844.png new file mode 100644 index 0000000..985dbc6 Binary files /dev/null and b/attached_assets/image_1766080135844.png differ diff --git a/attached_assets/image_1766080326850.png b/attached_assets/image_1766080326850.png new file mode 100644 index 0000000..8a1e77d Binary files /dev/null and b/attached_assets/image_1766080326850.png differ diff --git a/attached_assets/image_1766080687545.png b/attached_assets/image_1766080687545.png new file mode 100644 index 0000000..bed78f4 Binary files /dev/null and b/attached_assets/image_1766080687545.png differ diff --git a/attached_assets/image_1766081186080.png b/attached_assets/image_1766081186080.png new file mode 100644 index 0000000..b5e2260 Binary files /dev/null and b/attached_assets/image_1766081186080.png differ diff --git a/attached_assets/image_1766081562853.png b/attached_assets/image_1766081562853.png new file mode 100644 index 0000000..2ab940f Binary files /dev/null and b/attached_assets/image_1766081562853.png differ diff --git a/attached_assets/image_1766081814793.png b/attached_assets/image_1766081814793.png new file mode 100644 index 0000000..d4d0a05 Binary files /dev/null and b/attached_assets/image_1766081814793.png differ diff --git a/attached_assets/image_1766082272695.png b/attached_assets/image_1766082272695.png new file mode 100644 index 0000000..f832b41 Binary files /dev/null and b/attached_assets/image_1766082272695.png differ diff --git a/attached_assets/image_1766086475109.png b/attached_assets/image_1766086475109.png new file mode 100644 index 0000000..e037a24 Binary files /dev/null and b/attached_assets/image_1766086475109.png differ diff --git a/attached_assets/image_1766091645005.png b/attached_assets/image_1766091645005.png new file mode 100644 index 0000000..eeee4f0 Binary files /dev/null and b/attached_assets/image_1766091645005.png differ diff --git a/attached_assets/image_1766091784969.png b/attached_assets/image_1766091784969.png new file mode 100644 index 0000000..743edd7 Binary files /dev/null and b/attached_assets/image_1766091784969.png differ diff --git a/attached_assets/image_1766404359913.png b/attached_assets/image_1766404359913.png new file mode 100644 index 0000000..0d4c13d Binary files /dev/null and b/attached_assets/image_1766404359913.png differ diff --git a/attached_assets/image_1766404795490.png b/attached_assets/image_1766404795490.png new file mode 100644 index 0000000..5bee93f Binary files /dev/null and b/attached_assets/image_1766404795490.png differ diff --git a/attached_assets/image_1766406515972.png b/attached_assets/image_1766406515972.png new file mode 100644 index 0000000..19abff6 Binary files /dev/null and b/attached_assets/image_1766406515972.png differ diff --git a/attached_assets/image_1766408377563.png b/attached_assets/image_1766408377563.png new file mode 100644 index 0000000..cda4f68 Binary files /dev/null and b/attached_assets/image_1766408377563.png differ diff --git a/attached_assets/image_1766409740705.png b/attached_assets/image_1766409740705.png new file mode 100644 index 0000000..70ad89c Binary files /dev/null and b/attached_assets/image_1766409740705.png differ diff --git a/attached_assets/image_1766411550307.png b/attached_assets/image_1766411550307.png new file mode 100644 index 0000000..5104e9d Binary files /dev/null and b/attached_assets/image_1766411550307.png differ diff --git a/attached_assets/image_1766412803820.png b/attached_assets/image_1766412803820.png new file mode 100644 index 0000000..4916409 Binary files /dev/null and b/attached_assets/image_1766412803820.png differ diff --git a/attached_assets/image_1766430105608.png b/attached_assets/image_1766430105608.png new file mode 100644 index 0000000..376af1c Binary files /dev/null and b/attached_assets/image_1766430105608.png differ diff --git a/attached_assets/image_1766430973904.png b/attached_assets/image_1766430973904.png new file mode 100644 index 0000000..12e46db Binary files /dev/null and b/attached_assets/image_1766430973904.png differ diff --git a/attached_assets/image_1766430994558.png b/attached_assets/image_1766430994558.png new file mode 100644 index 0000000..820bfc5 Binary files /dev/null and b/attached_assets/image_1766430994558.png differ diff --git a/attached_assets/image_1766431173691.png b/attached_assets/image_1766431173691.png new file mode 100644 index 0000000..c4703a5 Binary files /dev/null and b/attached_assets/image_1766431173691.png differ diff --git a/attached_assets/image_1766431956138.png b/attached_assets/image_1766431956138.png new file mode 100644 index 0000000..863f8ce Binary files /dev/null and b/attached_assets/image_1766431956138.png differ diff --git a/attached_assets/image_1766432004325.png b/attached_assets/image_1766432004325.png new file mode 100644 index 0000000..41c4c5c Binary files /dev/null and b/attached_assets/image_1766432004325.png differ diff --git a/attached_assets/image_1766432442505.png b/attached_assets/image_1766432442505.png new file mode 100644 index 0000000..970e547 Binary files /dev/null and b/attached_assets/image_1766432442505.png differ diff --git a/attached_assets/image_1766433918139.png b/attached_assets/image_1766433918139.png new file mode 100644 index 0000000..80b0fbf Binary files /dev/null and b/attached_assets/image_1766433918139.png differ diff --git a/attached_assets/image_1766438024148.png b/attached_assets/image_1766438024148.png new file mode 100644 index 0000000..18ed348 Binary files /dev/null and b/attached_assets/image_1766438024148.png differ diff --git a/attached_assets/image_1766438184375.png b/attached_assets/image_1766438184375.png new file mode 100644 index 0000000..6736931 Binary files /dev/null and b/attached_assets/image_1766438184375.png differ diff --git a/attached_assets/image_1766438540939.png b/attached_assets/image_1766438540939.png new file mode 100644 index 0000000..6479dde Binary files /dev/null and b/attached_assets/image_1766438540939.png differ diff --git a/attached_assets/image_1766438697001.png b/attached_assets/image_1766438697001.png new file mode 100644 index 0000000..667f5b3 Binary files /dev/null and b/attached_assets/image_1766438697001.png differ diff --git a/attached_assets/image_1766438979478.png b/attached_assets/image_1766438979478.png new file mode 100644 index 0000000..9adcfda Binary files /dev/null and b/attached_assets/image_1766438979478.png differ diff --git a/attached_assets/image_1766494739184.png b/attached_assets/image_1766494739184.png new file mode 100644 index 0000000..c3c8a43 Binary files /dev/null and b/attached_assets/image_1766494739184.png differ diff --git a/attached_assets/image_1766692549293.png b/attached_assets/image_1766692549293.png new file mode 100644 index 0000000..9cadf66 Binary files /dev/null and b/attached_assets/image_1766692549293.png differ diff --git a/attached_assets/image_1766693444921.png b/attached_assets/image_1766693444921.png new file mode 100644 index 0000000..ecdda4b Binary files /dev/null and b/attached_assets/image_1766693444921.png differ diff --git a/attached_assets/image_1766693929609.png b/attached_assets/image_1766693929609.png new file mode 100644 index 0000000..464ed22 Binary files /dev/null and b/attached_assets/image_1766693929609.png differ diff --git a/attached_assets/image_1766732944225.png b/attached_assets/image_1766732944225.png new file mode 100644 index 0000000..729e61b Binary files /dev/null and b/attached_assets/image_1766732944225.png differ diff --git a/attached_assets/image_1766733729067.png b/attached_assets/image_1766733729067.png new file mode 100644 index 0000000..ff34dde Binary files /dev/null and b/attached_assets/image_1766733729067.png differ diff --git a/attached_assets/image_1766739265172.png b/attached_assets/image_1766739265172.png new file mode 100644 index 0000000..de342bf Binary files /dev/null and b/attached_assets/image_1766739265172.png differ diff --git a/attached_assets/image_1766739650186.png b/attached_assets/image_1766739650186.png new file mode 100644 index 0000000..e734a8a Binary files /dev/null and b/attached_assets/image_1766739650186.png differ diff --git a/attached_assets/image_1766746079474.png b/attached_assets/image_1766746079474.png new file mode 100644 index 0000000..ac9b9a0 Binary files /dev/null and b/attached_assets/image_1766746079474.png differ diff --git a/attached_assets/image_1766746126130.png b/attached_assets/image_1766746126130.png new file mode 100644 index 0000000..f495820 Binary files /dev/null and b/attached_assets/image_1766746126130.png differ diff --git a/attached_assets/image_1766754033612.png b/attached_assets/image_1766754033612.png new file mode 100644 index 0000000..558f86d Binary files /dev/null and b/attached_assets/image_1766754033612.png differ diff --git a/attached_assets/image_1766755247872.png b/attached_assets/image_1766755247872.png new file mode 100644 index 0000000..58c6b07 Binary files /dev/null and b/attached_assets/image_1766755247872.png differ diff --git a/attached_assets/image_1767952653796.png b/attached_assets/image_1767952653796.png new file mode 100644 index 0000000..2f6bced Binary files /dev/null and b/attached_assets/image_1767952653796.png differ diff --git a/attached_assets/image_1767953387908.png b/attached_assets/image_1767953387908.png new file mode 100644 index 0000000..76106f9 Binary files /dev/null and b/attached_assets/image_1767953387908.png differ diff --git a/attached_assets/image_1767954457826.png b/attached_assets/image_1767954457826.png new file mode 100644 index 0000000..08ce137 Binary files /dev/null and b/attached_assets/image_1767954457826.png differ diff --git a/attached_assets/image_1767955777909.png b/attached_assets/image_1767955777909.png new file mode 100644 index 0000000..cc1f1ec Binary files /dev/null and b/attached_assets/image_1767955777909.png differ diff --git a/attached_assets/image_1767956814227.png b/attached_assets/image_1767956814227.png new file mode 100644 index 0000000..e5647f9 Binary files /dev/null and b/attached_assets/image_1767956814227.png differ diff --git a/attached_assets/image_1767957257530.png b/attached_assets/image_1767957257530.png new file mode 100644 index 0000000..916dfd5 Binary files /dev/null and b/attached_assets/image_1767957257530.png differ diff --git a/attached_assets/image_1767957297748.png b/attached_assets/image_1767957297748.png new file mode 100644 index 0000000..b0bdf66 Binary files /dev/null and b/attached_assets/image_1767957297748.png differ diff --git a/attached_assets/image_1768941080706.png b/attached_assets/image_1768941080706.png new file mode 100644 index 0000000..56cc997 Binary files /dev/null and b/attached_assets/image_1768941080706.png differ diff --git a/attached_assets/image_1768944441209.png b/attached_assets/image_1768944441209.png new file mode 100644 index 0000000..5fbfdbd Binary files /dev/null and b/attached_assets/image_1768944441209.png differ diff --git a/attached_assets/image_1769516821674.png b/attached_assets/image_1769516821674.png new file mode 100644 index 0000000..a0aa825 Binary files /dev/null and b/attached_assets/image_1769516821674.png differ diff --git a/attached_assets/image_1769517010597.png b/attached_assets/image_1769517010597.png new file mode 100644 index 0000000..076299f Binary files /dev/null and b/attached_assets/image_1769517010597.png differ diff --git a/attached_assets/image_1769517031407.png b/attached_assets/image_1769517031407.png new file mode 100644 index 0000000..5199f14 Binary files /dev/null and b/attached_assets/image_1769517031407.png differ diff --git a/attached_assets/image_1769517167535.png b/attached_assets/image_1769517167535.png new file mode 100644 index 0000000..5199f14 Binary files /dev/null and b/attached_assets/image_1769517167535.png differ diff --git a/attached_assets/image_1769517286072.png b/attached_assets/image_1769517286072.png new file mode 100644 index 0000000..02a9d6a Binary files /dev/null and b/attached_assets/image_1769517286072.png differ diff --git a/attached_assets/image_1769518173699.png b/attached_assets/image_1769518173699.png new file mode 100644 index 0000000..8a76d2c Binary files /dev/null and b/attached_assets/image_1769518173699.png differ diff --git a/attached_assets/image_1769518349361.png b/attached_assets/image_1769518349361.png new file mode 100644 index 0000000..1f71698 Binary files /dev/null and b/attached_assets/image_1769518349361.png differ diff --git a/attached_assets/image_1769519843443.png b/attached_assets/image_1769519843443.png new file mode 100644 index 0000000..673decb Binary files /dev/null and b/attached_assets/image_1769519843443.png differ diff --git a/attached_assets/image_1769519968455.png b/attached_assets/image_1769519968455.png new file mode 100644 index 0000000..dd350cb Binary files /dev/null and b/attached_assets/image_1769519968455.png differ diff --git a/attached_assets/image_1769976243765.png b/attached_assets/image_1769976243765.png new file mode 100644 index 0000000..b3a545c Binary files /dev/null and b/attached_assets/image_1769976243765.png differ diff --git a/attached_assets/image_1769976322788.png b/attached_assets/image_1769976322788.png new file mode 100644 index 0000000..6af790e Binary files /dev/null and b/attached_assets/image_1769976322788.png differ diff --git a/attached_assets/image_1769979001473.png b/attached_assets/image_1769979001473.png new file mode 100644 index 0000000..9bb29c5 Binary files /dev/null and b/attached_assets/image_1769979001473.png differ diff --git a/attached_assets/image_1769979091600.png b/attached_assets/image_1769979091600.png new file mode 100644 index 0000000..6cc1ecc Binary files /dev/null and b/attached_assets/image_1769979091600.png differ diff --git a/attached_assets/image_1769979146123.png b/attached_assets/image_1769979146123.png new file mode 100644 index 0000000..750d477 Binary files /dev/null and b/attached_assets/image_1769979146123.png differ diff --git a/attached_assets/image_1769980094184.png b/attached_assets/image_1769980094184.png new file mode 100644 index 0000000..752e45a Binary files /dev/null and b/attached_assets/image_1769980094184.png differ diff --git a/attached_assets/image_1769980525999.png b/attached_assets/image_1769980525999.png new file mode 100644 index 0000000..6b932f5 Binary files /dev/null and b/attached_assets/image_1769980525999.png differ diff --git a/attached_assets/image_1769980704637.png b/attached_assets/image_1769980704637.png new file mode 100644 index 0000000..f5067c0 Binary files /dev/null and b/attached_assets/image_1769980704637.png differ diff --git a/attached_assets/image_1769981855747.png b/attached_assets/image_1769981855747.png new file mode 100644 index 0000000..4edd107 Binary files /dev/null and b/attached_assets/image_1769981855747.png differ diff --git a/attached_assets/image_1770237288099.png b/attached_assets/image_1770237288099.png new file mode 100644 index 0000000..befafda Binary files /dev/null and b/attached_assets/image_1770237288099.png differ diff --git a/attached_assets/image_1770237982523.png b/attached_assets/image_1770237982523.png new file mode 100644 index 0000000..50d3c71 Binary files /dev/null and b/attached_assets/image_1770237982523.png differ diff --git a/attached_assets/image_1770238433047.png b/attached_assets/image_1770238433047.png new file mode 100644 index 0000000..87ed09f Binary files /dev/null and b/attached_assets/image_1770238433047.png differ diff --git a/attached_assets/image_1770238655931.png b/attached_assets/image_1770238655931.png new file mode 100644 index 0000000..9e557b8 Binary files /dev/null and b/attached_assets/image_1770238655931.png differ diff --git a/attached_assets/image_1770242747425.png b/attached_assets/image_1770242747425.png new file mode 100644 index 0000000..f3a2612 Binary files /dev/null and b/attached_assets/image_1770242747425.png differ diff --git a/attached_assets/image_1770243397456.png b/attached_assets/image_1770243397456.png new file mode 100644 index 0000000..28ae0c6 Binary files /dev/null and b/attached_assets/image_1770243397456.png differ diff --git a/attached_assets/image_1770243460496.png b/attached_assets/image_1770243460496.png new file mode 100644 index 0000000..28ae0c6 Binary files /dev/null and b/attached_assets/image_1770243460496.png differ diff --git a/attached_assets/image_1771357149058.png b/attached_assets/image_1771357149058.png new file mode 100644 index 0000000..d620260 Binary files /dev/null and b/attached_assets/image_1771357149058.png differ diff --git a/attached_assets/image_1771357739969.png b/attached_assets/image_1771357739969.png new file mode 100644 index 0000000..7172549 Binary files /dev/null and b/attached_assets/image_1771357739969.png differ diff --git a/attached_assets/image_1774003587080.png b/attached_assets/image_1774003587080.png new file mode 100644 index 0000000..a804200 Binary files /dev/null and b/attached_assets/image_1774003587080.png differ diff --git a/attached_assets/image_1776003541996.png b/attached_assets/image_1776003541996.png new file mode 100644 index 0000000..a203550 Binary files /dev/null and b/attached_assets/image_1776003541996.png differ diff --git a/attached_assets/image_1776003747182.png b/attached_assets/image_1776003747182.png new file mode 100644 index 0000000..ee87759 Binary files /dev/null and b/attached_assets/image_1776003747182.png differ diff --git a/attached_assets/image_1776004089694.png b/attached_assets/image_1776004089694.png new file mode 100644 index 0000000..17a20e1 Binary files /dev/null and b/attached_assets/image_1776004089694.png differ diff --git a/attached_assets/image_1776004159893.png b/attached_assets/image_1776004159893.png new file mode 100644 index 0000000..e7a3135 Binary files /dev/null and b/attached_assets/image_1776004159893.png differ diff --git a/attached_assets/image_1776004264240.png b/attached_assets/image_1776004264240.png new file mode 100644 index 0000000..8674f1a Binary files /dev/null and b/attached_assets/image_1776004264240.png differ diff --git a/attached_assets/image_1776009645629.png b/attached_assets/image_1776009645629.png new file mode 100644 index 0000000..f3ba89e Binary files /dev/null and b/attached_assets/image_1776009645629.png differ diff --git a/attached_assets/image_1776009992176.png b/attached_assets/image_1776009992176.png new file mode 100644 index 0000000..1517990 Binary files /dev/null and b/attached_assets/image_1776009992176.png differ diff --git a/attached_assets/image_1776010052427.png b/attached_assets/image_1776010052427.png new file mode 100644 index 0000000..a47ca5c Binary files /dev/null and b/attached_assets/image_1776010052427.png differ diff --git a/attached_assets/image_1776010187673.png b/attached_assets/image_1776010187673.png new file mode 100644 index 0000000..bef3053 Binary files /dev/null and b/attached_assets/image_1776010187673.png differ diff --git a/attached_assets/image_1776011577196.png b/attached_assets/image_1776011577196.png new file mode 100644 index 0000000..96799df Binary files /dev/null and b/attached_assets/image_1776011577196.png differ diff --git a/attached_assets/image_1776011619994.png b/attached_assets/image_1776011619994.png new file mode 100644 index 0000000..d9733f9 Binary files /dev/null and b/attached_assets/image_1776011619994.png differ diff --git a/attached_assets/image_1776012297220.png b/attached_assets/image_1776012297220.png new file mode 100644 index 0000000..8b0b241 Binary files /dev/null and b/attached_assets/image_1776012297220.png differ diff --git a/attached_assets/image_1776014983677.png b/attached_assets/image_1776014983677.png new file mode 100644 index 0000000..547b2f6 Binary files /dev/null and b/attached_assets/image_1776014983677.png differ diff --git a/attached_assets/image_1776015007043.png b/attached_assets/image_1776015007043.png new file mode 100644 index 0000000..0452303 Binary files /dev/null and b/attached_assets/image_1776015007043.png differ diff --git a/attached_assets/image_1776020422518.png b/attached_assets/image_1776020422518.png new file mode 100644 index 0000000..a36c69b Binary files /dev/null and b/attached_assets/image_1776020422518.png differ diff --git a/attached_assets/image_1776021003981.png b/attached_assets/image_1776021003981.png new file mode 100644 index 0000000..fa3167a Binary files /dev/null and b/attached_assets/image_1776021003981.png differ diff --git a/attached_assets/image_1776021446978.png b/attached_assets/image_1776021446978.png new file mode 100644 index 0000000..41395eb Binary files /dev/null and b/attached_assets/image_1776021446978.png differ diff --git a/attached_assets/image_1776021794655.png b/attached_assets/image_1776021794655.png new file mode 100644 index 0000000..e07c03f Binary files /dev/null and b/attached_assets/image_1776021794655.png differ diff --git a/attached_assets/image_1776022630688.png b/attached_assets/image_1776022630688.png new file mode 100644 index 0000000..8e8cd92 Binary files /dev/null and b/attached_assets/image_1776022630688.png differ diff --git a/attached_assets/image_1776062296886.png b/attached_assets/image_1776062296886.png new file mode 100644 index 0000000..cdf232a Binary files /dev/null and b/attached_assets/image_1776062296886.png differ diff --git a/attached_assets/image_1776063030574.png b/attached_assets/image_1776063030574.png new file mode 100644 index 0000000..284b029 Binary files /dev/null and b/attached_assets/image_1776063030574.png differ diff --git a/attached_assets/image_1776063120513.png b/attached_assets/image_1776063120513.png new file mode 100644 index 0000000..940b94d Binary files /dev/null and b/attached_assets/image_1776063120513.png differ diff --git a/attached_assets/image_1776066835690.png b/attached_assets/image_1776066835690.png new file mode 100644 index 0000000..13743c0 Binary files /dev/null and b/attached_assets/image_1776066835690.png differ diff --git a/attached_assets/image_1776067302130.png b/attached_assets/image_1776067302130.png new file mode 100644 index 0000000..1d1ff9c Binary files /dev/null and b/attached_assets/image_1776067302130.png differ diff --git a/attached_assets/image_1776069432246.png b/attached_assets/image_1776069432246.png new file mode 100644 index 0000000..3420376 Binary files /dev/null and b/attached_assets/image_1776069432246.png differ diff --git a/attached_assets/image_1776103310138.png b/attached_assets/image_1776103310138.png new file mode 100644 index 0000000..344cae2 Binary files /dev/null and b/attached_assets/image_1776103310138.png differ diff --git a/attached_assets/image_1776105361490.png b/attached_assets/image_1776105361490.png new file mode 100644 index 0000000..7bda06b Binary files /dev/null and b/attached_assets/image_1776105361490.png differ diff --git a/attached_assets/image_1776107196685.png b/attached_assets/image_1776107196685.png new file mode 100644 index 0000000..8e3ca60 Binary files /dev/null and b/attached_assets/image_1776107196685.png differ diff --git a/attached_assets/image_1776108671398.png b/attached_assets/image_1776108671398.png new file mode 100644 index 0000000..60b3d18 Binary files /dev/null and b/attached_assets/image_1776108671398.png differ diff --git a/attached_assets/image_1776109511689.png b/attached_assets/image_1776109511689.png new file mode 100644 index 0000000..8cde9e5 Binary files /dev/null and b/attached_assets/image_1776109511689.png differ diff --git a/attached_assets/image_1776112129155.png b/attached_assets/image_1776112129155.png new file mode 100644 index 0000000..cc45747 Binary files /dev/null and b/attached_assets/image_1776112129155.png differ diff --git a/attached_assets/image_1776113423821.png b/attached_assets/image_1776113423821.png new file mode 100644 index 0000000..ce19312 Binary files /dev/null and b/attached_assets/image_1776113423821.png differ diff --git a/attached_assets/image_1776113732260.png b/attached_assets/image_1776113732260.png new file mode 100644 index 0000000..7952dd6 Binary files /dev/null and b/attached_assets/image_1776113732260.png differ diff --git a/attached_assets/image_1776256139763.png b/attached_assets/image_1776256139763.png new file mode 100644 index 0000000..d10ec0e Binary files /dev/null and b/attached_assets/image_1776256139763.png differ diff --git a/attached_assets/image_1776258073424.png b/attached_assets/image_1776258073424.png new file mode 100644 index 0000000..c0be53b Binary files /dev/null and b/attached_assets/image_1776258073424.png differ diff --git a/attached_assets/image_1776258814420.png b/attached_assets/image_1776258814420.png new file mode 100644 index 0000000..b9a53b0 Binary files /dev/null and b/attached_assets/image_1776258814420.png differ diff --git a/attached_assets/image_1776258855667.png b/attached_assets/image_1776258855667.png new file mode 100644 index 0000000..05be8bc Binary files /dev/null and b/attached_assets/image_1776258855667.png differ diff --git a/attached_assets/image_1776258959250.png b/attached_assets/image_1776258959250.png new file mode 100644 index 0000000..6012e37 Binary files /dev/null and b/attached_assets/image_1776258959250.png differ diff --git a/attached_assets/image_1776849763691.png b/attached_assets/image_1776849763691.png new file mode 100644 index 0000000..7559914 Binary files /dev/null and b/attached_assets/image_1776849763691.png differ diff --git a/attached_assets/image_1776855149906.png b/attached_assets/image_1776855149906.png new file mode 100644 index 0000000..905a65b Binary files /dev/null and b/attached_assets/image_1776855149906.png differ diff --git a/attached_assets/image_1776855245894.png b/attached_assets/image_1776855245894.png new file mode 100644 index 0000000..2dde5e8 Binary files /dev/null and b/attached_assets/image_1776855245894.png differ diff --git a/attached_assets/image_1776856231600.png b/attached_assets/image_1776856231600.png new file mode 100644 index 0000000..4f76f6d Binary files /dev/null and b/attached_assets/image_1776856231600.png differ diff --git a/attached_assets/image_1776860103951.png b/attached_assets/image_1776860103951.png new file mode 100644 index 0000000..ccca62c Binary files /dev/null and b/attached_assets/image_1776860103951.png differ diff --git a/attached_assets/image_1776862455697.png b/attached_assets/image_1776862455697.png new file mode 100644 index 0000000..84728f6 Binary files /dev/null and b/attached_assets/image_1776862455697.png differ diff --git a/attached_assets/image_1776863792970.png b/attached_assets/image_1776863792970.png new file mode 100644 index 0000000..ccf9923 Binary files /dev/null and b/attached_assets/image_1776863792970.png differ diff --git a/attached_assets/image_1776864395342.png b/attached_assets/image_1776864395342.png new file mode 100644 index 0000000..fe5e501 Binary files /dev/null and b/attached_assets/image_1776864395342.png differ diff --git a/attached_assets/image_1776883634776.png b/attached_assets/image_1776883634776.png new file mode 100644 index 0000000..ce2c5c0 Binary files /dev/null and b/attached_assets/image_1776883634776.png differ diff --git a/attached_assets/image_1776883705038.png b/attached_assets/image_1776883705038.png new file mode 100644 index 0000000..421bba6 Binary files /dev/null and b/attached_assets/image_1776883705038.png differ diff --git a/attached_assets/image_1776886511110.png b/attached_assets/image_1776886511110.png new file mode 100644 index 0000000..4e5d18b Binary files /dev/null and b/attached_assets/image_1776886511110.png differ diff --git a/attached_assets/image_1776886893448.png b/attached_assets/image_1776886893448.png new file mode 100644 index 0000000..0d345ec Binary files /dev/null and b/attached_assets/image_1776886893448.png differ diff --git a/attached_assets/image_1776886987302.png b/attached_assets/image_1776886987302.png new file mode 100644 index 0000000..0d345ec Binary files /dev/null and b/attached_assets/image_1776886987302.png differ diff --git a/attached_assets/image_1776887171012.png b/attached_assets/image_1776887171012.png new file mode 100644 index 0000000..c6d1f19 Binary files /dev/null and b/attached_assets/image_1776887171012.png differ diff --git a/attached_assets/image_1776887232864.png b/attached_assets/image_1776887232864.png new file mode 100644 index 0000000..bc1d0b7 Binary files /dev/null and b/attached_assets/image_1776887232864.png differ diff --git a/attached_assets/image_1776888876707.png b/attached_assets/image_1776888876707.png new file mode 100644 index 0000000..b44ea0e Binary files /dev/null and b/attached_assets/image_1776888876707.png differ diff --git a/attached_assets/image_1776889008407.png b/attached_assets/image_1776889008407.png new file mode 100644 index 0000000..f3bbe31 Binary files /dev/null and b/attached_assets/image_1776889008407.png differ diff --git a/attached_assets/image_1776889140564.png b/attached_assets/image_1776889140564.png new file mode 100644 index 0000000..13936df Binary files /dev/null and b/attached_assets/image_1776889140564.png differ diff --git a/attached_assets/image_1776948055062.png b/attached_assets/image_1776948055062.png new file mode 100644 index 0000000..0b5a665 Binary files /dev/null and b/attached_assets/image_1776948055062.png differ diff --git a/attached_assets/image_1777366595025.png b/attached_assets/image_1777366595025.png new file mode 100644 index 0000000..db057aa Binary files /dev/null and b/attached_assets/image_1777366595025.png differ diff --git a/attached_assets/image_1777382445094.png b/attached_assets/image_1777382445094.png new file mode 100644 index 0000000..b2517cb Binary files /dev/null and b/attached_assets/image_1777382445094.png differ diff --git a/attached_assets/image_1777383608001.png b/attached_assets/image_1777383608001.png new file mode 100644 index 0000000..e106e4b Binary files /dev/null and b/attached_assets/image_1777383608001.png differ diff --git a/attached_assets/image_1777449489383.png b/attached_assets/image_1777449489383.png new file mode 100644 index 0000000..81b6842 Binary files /dev/null and b/attached_assets/image_1777449489383.png differ diff --git a/attached_assets/image_1777450537452.png b/attached_assets/image_1777450537452.png new file mode 100644 index 0000000..b98fa6e Binary files /dev/null and b/attached_assets/image_1777450537452.png differ diff --git a/attached_assets/image_1777450998902.png b/attached_assets/image_1777450998902.png new file mode 100644 index 0000000..3f9663c Binary files /dev/null and b/attached_assets/image_1777450998902.png differ diff --git a/attached_assets/image_1777451158507.png b/attached_assets/image_1777451158507.png new file mode 100644 index 0000000..a56d24a Binary files /dev/null and b/attached_assets/image_1777451158507.png differ diff --git a/attached_assets/image_1777451545199.png b/attached_assets/image_1777451545199.png new file mode 100644 index 0000000..c19129c Binary files /dev/null and b/attached_assets/image_1777451545199.png differ diff --git a/attached_assets/image_1777451706373.png b/attached_assets/image_1777451706373.png new file mode 100644 index 0000000..1c6d2f5 Binary files /dev/null and b/attached_assets/image_1777451706373.png differ diff --git a/attached_assets/image_1777451749702.png b/attached_assets/image_1777451749702.png new file mode 100644 index 0000000..55bfd73 Binary files /dev/null and b/attached_assets/image_1777451749702.png differ diff --git a/attached_assets/image_1777451800997.png b/attached_assets/image_1777451800997.png new file mode 100644 index 0000000..ae97ff9 Binary files /dev/null and b/attached_assets/image_1777451800997.png differ diff --git a/attached_assets/image_1777452421539.png b/attached_assets/image_1777452421539.png new file mode 100644 index 0000000..f55958e Binary files /dev/null and b/attached_assets/image_1777452421539.png differ diff --git a/attached_assets/image_1777452495707.png b/attached_assets/image_1777452495707.png new file mode 100644 index 0000000..3d889cd Binary files /dev/null and b/attached_assets/image_1777452495707.png differ diff --git a/attached_assets/image_1777470498050.png b/attached_assets/image_1777470498050.png new file mode 100644 index 0000000..5f1c154 Binary files /dev/null and b/attached_assets/image_1777470498050.png differ diff --git a/attached_assets/image_1777529201543.png b/attached_assets/image_1777529201543.png new file mode 100644 index 0000000..b6750f9 Binary files /dev/null and b/attached_assets/image_1777529201543.png differ diff --git a/attached_assets/image_1777530137066.png b/attached_assets/image_1777530137066.png new file mode 100644 index 0000000..f5421a7 Binary files /dev/null and b/attached_assets/image_1777530137066.png differ diff --git a/attached_assets/image_1777530611471.png b/attached_assets/image_1777530611471.png new file mode 100644 index 0000000..0b4b766 Binary files /dev/null and b/attached_assets/image_1777530611471.png differ diff --git a/attached_assets/image_1777530818686.png b/attached_assets/image_1777530818686.png new file mode 100644 index 0000000..f41fcf8 Binary files /dev/null and b/attached_assets/image_1777530818686.png differ diff --git a/attached_assets/image_1777531055027.png b/attached_assets/image_1777531055027.png new file mode 100644 index 0000000..6e8334e Binary files /dev/null and b/attached_assets/image_1777531055027.png differ diff --git a/attached_assets/image_1777531163573.png b/attached_assets/image_1777531163573.png new file mode 100644 index 0000000..8cf0a99 Binary files /dev/null and b/attached_assets/image_1777531163573.png differ diff --git a/attached_assets/image_1777531373800.png b/attached_assets/image_1777531373800.png new file mode 100644 index 0000000..96ff13d Binary files /dev/null and b/attached_assets/image_1777531373800.png differ diff --git a/attached_assets/image_1777532312180.png b/attached_assets/image_1777532312180.png new file mode 100644 index 0000000..eee7c7d Binary files /dev/null and b/attached_assets/image_1777532312180.png differ diff --git a/attached_assets/image_1777532449172.png b/attached_assets/image_1777532449172.png new file mode 100644 index 0000000..de65718 Binary files /dev/null and b/attached_assets/image_1777532449172.png differ diff --git a/attached_assets/image_1777533088918.png b/attached_assets/image_1777533088918.png new file mode 100644 index 0000000..3801577 Binary files /dev/null and b/attached_assets/image_1777533088918.png differ diff --git a/attached_assets/image_1777536691118.png b/attached_assets/image_1777536691118.png new file mode 100644 index 0000000..6c0a2a5 Binary files /dev/null and b/attached_assets/image_1777536691118.png differ diff --git a/attached_assets/image_1777537088966.png b/attached_assets/image_1777537088966.png new file mode 100644 index 0000000..1ac2feb Binary files /dev/null and b/attached_assets/image_1777537088966.png differ diff --git a/attached_assets/image_1777537197179.png b/attached_assets/image_1777537197179.png new file mode 100644 index 0000000..1ac2feb Binary files /dev/null and b/attached_assets/image_1777537197179.png differ diff --git a/attached_assets/image_1777537859348.png b/attached_assets/image_1777537859348.png new file mode 100644 index 0000000..18b1d0b Binary files /dev/null and b/attached_assets/image_1777537859348.png differ diff --git a/attached_assets/image_1777538473277.png b/attached_assets/image_1777538473277.png new file mode 100644 index 0000000..ee1bccb Binary files /dev/null and b/attached_assets/image_1777538473277.png differ diff --git a/attached_assets/image_1777538480783.png b/attached_assets/image_1777538480783.png new file mode 100644 index 0000000..b2b9b11 Binary files /dev/null and b/attached_assets/image_1777538480783.png differ diff --git a/attached_assets/image_1777539014673.png b/attached_assets/image_1777539014673.png new file mode 100644 index 0000000..5562d48 Binary files /dev/null and b/attached_assets/image_1777539014673.png differ diff --git a/attached_assets/image_1777539149774.png b/attached_assets/image_1777539149774.png new file mode 100644 index 0000000..a5c9f9a Binary files /dev/null and b/attached_assets/image_1777539149774.png differ diff --git a/attached_assets/image_1777663265667.png b/attached_assets/image_1777663265667.png new file mode 100644 index 0000000..7179f07 Binary files /dev/null and b/attached_assets/image_1777663265667.png differ diff --git a/attached_assets/image_1777665471017.png b/attached_assets/image_1777665471017.png new file mode 100644 index 0000000..13153af Binary files /dev/null and b/attached_assets/image_1777665471017.png differ diff --git a/attached_assets/image_1777665632169.png b/attached_assets/image_1777665632169.png new file mode 100644 index 0000000..2089efb Binary files /dev/null and b/attached_assets/image_1777665632169.png differ diff --git a/attached_assets/image_1777665678602.png b/attached_assets/image_1777665678602.png new file mode 100644 index 0000000..eaf2eba Binary files /dev/null and b/attached_assets/image_1777665678602.png differ diff --git a/attached_assets/image_1777665968722.png b/attached_assets/image_1777665968722.png new file mode 100644 index 0000000..9428293 Binary files /dev/null and b/attached_assets/image_1777665968722.png differ diff --git a/attached_assets/image_1777891735750.png b/attached_assets/image_1777891735750.png new file mode 100644 index 0000000..5e13fd4 Binary files /dev/null and b/attached_assets/image_1777891735750.png differ diff --git a/attached_assets/image_1777892110033.png b/attached_assets/image_1777892110033.png new file mode 100644 index 0000000..28d3e6f Binary files /dev/null and b/attached_assets/image_1777892110033.png differ diff --git a/attached_assets/image_1777898652977.png b/attached_assets/image_1777898652977.png new file mode 100644 index 0000000..4c4dc5b Binary files /dev/null and b/attached_assets/image_1777898652977.png differ diff --git a/attached_assets/image_1778151790704.png b/attached_assets/image_1778151790704.png new file mode 100644 index 0000000..7ffcf74 Binary files /dev/null and b/attached_assets/image_1778151790704.png differ diff --git a/attached_assets/image_1778153473801.png b/attached_assets/image_1778153473801.png new file mode 100644 index 0000000..02d9dbf Binary files /dev/null and b/attached_assets/image_1778153473801.png differ diff --git a/attached_assets/image_1778157578563.png b/attached_assets/image_1778157578563.png new file mode 100644 index 0000000..e005981 Binary files /dev/null and b/attached_assets/image_1778157578563.png differ diff --git a/attached_assets/image_1778157839511.png b/attached_assets/image_1778157839511.png new file mode 100644 index 0000000..604967a Binary files /dev/null and b/attached_assets/image_1778157839511.png differ diff --git a/attached_assets/image_1778157922552.png b/attached_assets/image_1778157922552.png new file mode 100644 index 0000000..f0e544f Binary files /dev/null and b/attached_assets/image_1778157922552.png differ diff --git a/attached_assets/image_1778159908563.png b/attached_assets/image_1778159908563.png new file mode 100644 index 0000000..52f60dc Binary files /dev/null and b/attached_assets/image_1778159908563.png differ diff --git a/attached_assets/image_1778168455262.png b/attached_assets/image_1778168455262.png new file mode 100644 index 0000000..58848a4 Binary files /dev/null and b/attached_assets/image_1778168455262.png differ diff --git a/attached_assets/image_1778174360940.png b/attached_assets/image_1778174360940.png new file mode 100644 index 0000000..e11dd6f Binary files /dev/null and b/attached_assets/image_1778174360940.png differ diff --git a/attached_assets/image_1778174917116.png b/attached_assets/image_1778174917116.png new file mode 100644 index 0000000..c545926 Binary files /dev/null and b/attached_assets/image_1778174917116.png differ diff --git a/attached_assets/image_1778177512872.png b/attached_assets/image_1778177512872.png new file mode 100644 index 0000000..bdd7d50 Binary files /dev/null and b/attached_assets/image_1778177512872.png differ diff --git a/attached_assets/image_1778178024427.png b/attached_assets/image_1778178024427.png new file mode 100644 index 0000000..819a637 Binary files /dev/null and b/attached_assets/image_1778178024427.png differ diff --git a/attached_assets/image_1778236446703.png b/attached_assets/image_1778236446703.png new file mode 100644 index 0000000..20d752b Binary files /dev/null and b/attached_assets/image_1778236446703.png differ diff --git a/attached_assets/image_1778236611020.png b/attached_assets/image_1778236611020.png new file mode 100644 index 0000000..9b2dabd Binary files /dev/null and b/attached_assets/image_1778236611020.png differ diff --git a/attached_assets/image_1778237572029.png b/attached_assets/image_1778237572029.png new file mode 100644 index 0000000..0946082 Binary files /dev/null and b/attached_assets/image_1778237572029.png differ diff --git a/attached_assets/image_1778237779120.png b/attached_assets/image_1778237779120.png new file mode 100644 index 0000000..33d12e6 Binary files /dev/null and b/attached_assets/image_1778237779120.png differ diff --git a/attached_assets/image_1778238257757.png b/attached_assets/image_1778238257757.png new file mode 100644 index 0000000..7a5e11a Binary files /dev/null and b/attached_assets/image_1778238257757.png differ diff --git a/attached_assets/image_1778238479995.png b/attached_assets/image_1778238479995.png new file mode 100644 index 0000000..1e8abed Binary files /dev/null and b/attached_assets/image_1778238479995.png differ diff --git a/attached_assets/image_1778238480187.png b/attached_assets/image_1778238480187.png new file mode 100644 index 0000000..1e8abed Binary files /dev/null and b/attached_assets/image_1778238480187.png differ diff --git a/attached_assets/image_1778240446892.png b/attached_assets/image_1778240446892.png new file mode 100644 index 0000000..1a9767c Binary files /dev/null and b/attached_assets/image_1778240446892.png differ diff --git a/attached_assets/image_1778240831258.png b/attached_assets/image_1778240831258.png new file mode 100644 index 0000000..40d0f73 Binary files /dev/null and b/attached_assets/image_1778240831258.png differ diff --git a/attached_assets/image_1778242407026.png b/attached_assets/image_1778242407026.png new file mode 100644 index 0000000..8509139 Binary files /dev/null and b/attached_assets/image_1778242407026.png differ diff --git a/attached_assets/image_1778242500594.png b/attached_assets/image_1778242500594.png new file mode 100644 index 0000000..a94f0ff Binary files /dev/null and b/attached_assets/image_1778242500594.png differ diff --git a/attached_assets/image_1778242968474.png b/attached_assets/image_1778242968474.png new file mode 100644 index 0000000..e1a4199 Binary files /dev/null and b/attached_assets/image_1778242968474.png differ diff --git a/attached_assets/image_1778243248248.png b/attached_assets/image_1778243248248.png new file mode 100644 index 0000000..f8a9962 Binary files /dev/null and b/attached_assets/image_1778243248248.png differ diff --git a/attached_assets/image_1778243641210.png b/attached_assets/image_1778243641210.png new file mode 100644 index 0000000..e75c373 Binary files /dev/null and b/attached_assets/image_1778243641210.png differ diff --git a/attached_assets/image_1778244659709.png b/attached_assets/image_1778244659709.png new file mode 100644 index 0000000..c33fc0e Binary files /dev/null and b/attached_assets/image_1778244659709.png differ diff --git a/attached_assets/image_1778244868032.png b/attached_assets/image_1778244868032.png new file mode 100644 index 0000000..d214127 Binary files /dev/null and b/attached_assets/image_1778244868032.png differ diff --git a/attached_assets/image_1778245524575.png b/attached_assets/image_1778245524575.png new file mode 100644 index 0000000..32f9012 Binary files /dev/null and b/attached_assets/image_1778245524575.png differ diff --git a/attached_assets/image_1778245601138.png b/attached_assets/image_1778245601138.png new file mode 100644 index 0000000..04fdd0b Binary files /dev/null and b/attached_assets/image_1778245601138.png differ diff --git a/attached_assets/image_1778245694363.png b/attached_assets/image_1778245694363.png new file mode 100644 index 0000000..a1b58af Binary files /dev/null and b/attached_assets/image_1778245694363.png differ diff --git a/attached_assets/image_1778245701716.png b/attached_assets/image_1778245701716.png new file mode 100644 index 0000000..687ff4b Binary files /dev/null and b/attached_assets/image_1778245701716.png differ diff --git a/attached_assets/image_1778245745094.png b/attached_assets/image_1778245745094.png new file mode 100644 index 0000000..a1b58af Binary files /dev/null and b/attached_assets/image_1778245745094.png differ diff --git a/attached_assets/image_1778245748176.png b/attached_assets/image_1778245748176.png new file mode 100644 index 0000000..687ff4b Binary files /dev/null and b/attached_assets/image_1778245748176.png differ diff --git a/attached_assets/image_1778246073563.png b/attached_assets/image_1778246073563.png new file mode 100644 index 0000000..763cec9 Binary files /dev/null and b/attached_assets/image_1778246073563.png differ diff --git a/attached_assets/image_1778249785688.png b/attached_assets/image_1778249785688.png new file mode 100644 index 0000000..bd81a5a Binary files /dev/null and b/attached_assets/image_1778249785688.png differ diff --git a/attached_assets/image_1778250506903.png b/attached_assets/image_1778250506903.png new file mode 100644 index 0000000..fd18fd7 Binary files /dev/null and b/attached_assets/image_1778250506903.png differ diff --git a/attached_assets/image_1778260281607.png b/attached_assets/image_1778260281607.png new file mode 100644 index 0000000..f57bc7d Binary files /dev/null and b/attached_assets/image_1778260281607.png differ diff --git a/attached_assets/image_1778263547369.png b/attached_assets/image_1778263547369.png new file mode 100644 index 0000000..a532824 Binary files /dev/null and b/attached_assets/image_1778263547369.png differ diff --git a/attached_assets/image_1778264084410.png b/attached_assets/image_1778264084410.png new file mode 100644 index 0000000..8e3a29d Binary files /dev/null and b/attached_assets/image_1778264084410.png differ diff --git a/attached_assets/image_1778264948694.png b/attached_assets/image_1778264948694.png new file mode 100644 index 0000000..c988750 Binary files /dev/null and b/attached_assets/image_1778264948694.png differ diff --git a/attached_assets/image_1778314121492.png b/attached_assets/image_1778314121492.png new file mode 100644 index 0000000..ee03c08 Binary files /dev/null and b/attached_assets/image_1778314121492.png differ diff --git a/attached_assets/image_1778314201295.png b/attached_assets/image_1778314201295.png new file mode 100644 index 0000000..d97cbf6 Binary files /dev/null and b/attached_assets/image_1778314201295.png differ diff --git a/attached_assets/image_1778314624819.png b/attached_assets/image_1778314624819.png new file mode 100644 index 0000000..7b41711 Binary files /dev/null and b/attached_assets/image_1778314624819.png differ diff --git a/attached_assets/image_1778315454162.png b/attached_assets/image_1778315454162.png new file mode 100644 index 0000000..7734208 Binary files /dev/null and b/attached_assets/image_1778315454162.png differ diff --git a/attached_assets/image_1778315730223.png b/attached_assets/image_1778315730223.png new file mode 100644 index 0000000..c9e8ba0 Binary files /dev/null and b/attached_assets/image_1778315730223.png differ diff --git a/attached_assets/image_1778398004588.png b/attached_assets/image_1778398004588.png new file mode 100644 index 0000000..5461c3a Binary files /dev/null and b/attached_assets/image_1778398004588.png differ diff --git a/attached_assets/image_1778398058360.png b/attached_assets/image_1778398058360.png new file mode 100644 index 0000000..59798e1 Binary files /dev/null and b/attached_assets/image_1778398058360.png differ diff --git a/attached_assets/image_1778403632701.png b/attached_assets/image_1778403632701.png new file mode 100644 index 0000000..f099e5c Binary files /dev/null and b/attached_assets/image_1778403632701.png differ diff --git a/attached_assets/image_1778487610690.png b/attached_assets/image_1778487610690.png new file mode 100644 index 0000000..898c73c Binary files /dev/null and b/attached_assets/image_1778487610690.png differ diff --git a/attached_assets/image_1778488319760.png b/attached_assets/image_1778488319760.png new file mode 100644 index 0000000..f3c661e Binary files /dev/null and b/attached_assets/image_1778488319760.png differ diff --git a/attached_assets/image_1778488735123.png b/attached_assets/image_1778488735123.png new file mode 100644 index 0000000..5fe9c14 Binary files /dev/null and b/attached_assets/image_1778488735123.png differ diff --git a/attached_assets/image_1778488888321.png b/attached_assets/image_1778488888321.png new file mode 100644 index 0000000..6ce7c40 Binary files /dev/null and b/attached_assets/image_1778488888321.png differ diff --git a/attached_assets/image_1778488957643.png b/attached_assets/image_1778488957643.png new file mode 100644 index 0000000..8ea07f9 Binary files /dev/null and b/attached_assets/image_1778488957643.png differ diff --git a/attached_assets/image_1778505072074.png b/attached_assets/image_1778505072074.png new file mode 100644 index 0000000..3781011 Binary files /dev/null and b/attached_assets/image_1778505072074.png differ diff --git a/attached_assets/image_1778506937048.png b/attached_assets/image_1778506937048.png new file mode 100644 index 0000000..dfb51e7 Binary files /dev/null and b/attached_assets/image_1778506937048.png differ diff --git a/attached_assets/image_1778507058606.png b/attached_assets/image_1778507058606.png new file mode 100644 index 0000000..5230523 Binary files /dev/null and b/attached_assets/image_1778507058606.png differ diff --git a/attached_assets/image_1778507372167.png b/attached_assets/image_1778507372167.png new file mode 100644 index 0000000..9e2aed7 Binary files /dev/null and b/attached_assets/image_1778507372167.png differ diff --git a/attached_assets/image_1778507445428.png b/attached_assets/image_1778507445428.png new file mode 100644 index 0000000..72aac6f Binary files /dev/null and b/attached_assets/image_1778507445428.png differ diff --git a/attached_assets/image_1778507900371.png b/attached_assets/image_1778507900371.png new file mode 100644 index 0000000..5458961 Binary files /dev/null and b/attached_assets/image_1778507900371.png differ diff --git a/attached_assets/image_1778521299991.png b/attached_assets/image_1778521299991.png new file mode 100644 index 0000000..2550787 Binary files /dev/null and b/attached_assets/image_1778521299991.png differ diff --git a/attached_assets/image_1778521396032.png b/attached_assets/image_1778521396032.png new file mode 100644 index 0000000..6136194 Binary files /dev/null and b/attached_assets/image_1778521396032.png differ diff --git a/attached_assets/image_1778521994872.png b/attached_assets/image_1778521994872.png new file mode 100644 index 0000000..1afb5af Binary files /dev/null and b/attached_assets/image_1778521994872.png differ diff --git a/attached_assets/image_1778522441554.png b/attached_assets/image_1778522441554.png new file mode 100644 index 0000000..c119793 Binary files /dev/null and b/attached_assets/image_1778522441554.png differ diff --git a/attached_assets/image_1778522830147.png b/attached_assets/image_1778522830147.png new file mode 100644 index 0000000..a1641b3 Binary files /dev/null and b/attached_assets/image_1778522830147.png differ diff --git a/attached_assets/image_1778522845754.png b/attached_assets/image_1778522845754.png new file mode 100644 index 0000000..2d2eac4 Binary files /dev/null and b/attached_assets/image_1778522845754.png differ diff --git a/attached_assets/image_1778566246364.png b/attached_assets/image_1778566246364.png new file mode 100644 index 0000000..2e894b3 Binary files /dev/null and b/attached_assets/image_1778566246364.png differ diff --git a/attached_assets/image_1778567648907.png b/attached_assets/image_1778567648907.png new file mode 100644 index 0000000..d145f8f Binary files /dev/null and b/attached_assets/image_1778567648907.png differ diff --git a/attached_assets/image_1778568273980.png b/attached_assets/image_1778568273980.png new file mode 100644 index 0000000..95c2c57 Binary files /dev/null and b/attached_assets/image_1778568273980.png differ diff --git a/attached_assets/image_1778568521025.png b/attached_assets/image_1778568521025.png new file mode 100644 index 0000000..973ad78 Binary files /dev/null and b/attached_assets/image_1778568521025.png differ diff --git a/attached_assets/image_1778569391650.png b/attached_assets/image_1778569391650.png new file mode 100644 index 0000000..a7aabd4 Binary files /dev/null and b/attached_assets/image_1778569391650.png differ diff --git a/attached_assets/image_1778570433769.png b/attached_assets/image_1778570433769.png new file mode 100644 index 0000000..b6d9e7a Binary files /dev/null and b/attached_assets/image_1778570433769.png differ diff --git a/attached_assets/image_1778571795374.png b/attached_assets/image_1778571795374.png new file mode 100644 index 0000000..f0ba0cc Binary files /dev/null and b/attached_assets/image_1778571795374.png differ diff --git a/attached_assets/image_1778572320320.png b/attached_assets/image_1778572320320.png new file mode 100644 index 0000000..305c1bf Binary files /dev/null and b/attached_assets/image_1778572320320.png differ diff --git a/attached_assets/image_1778572367961.png b/attached_assets/image_1778572367961.png new file mode 100644 index 0000000..f355ec3 Binary files /dev/null and b/attached_assets/image_1778572367961.png differ diff --git a/attached_assets/image_1778572870378.png b/attached_assets/image_1778572870378.png new file mode 100644 index 0000000..1a85195 Binary files /dev/null and b/attached_assets/image_1778572870378.png differ diff --git a/attached_assets/image_1778573276483.png b/attached_assets/image_1778573276483.png new file mode 100644 index 0000000..b282b24 Binary files /dev/null and b/attached_assets/image_1778573276483.png differ diff --git a/attached_assets/image_1778574661258.png b/attached_assets/image_1778574661258.png new file mode 100644 index 0000000..af00b3f Binary files /dev/null and b/attached_assets/image_1778574661258.png differ diff --git a/attached_assets/image_1778585674110.png b/attached_assets/image_1778585674110.png new file mode 100644 index 0000000..99c3f50 Binary files /dev/null and b/attached_assets/image_1778585674110.png differ diff --git a/attached_assets/image_1778586734417.png b/attached_assets/image_1778586734417.png new file mode 100644 index 0000000..ee65705 Binary files /dev/null and b/attached_assets/image_1778586734417.png differ diff --git a/attached_assets/image_1778586822613.png b/attached_assets/image_1778586822613.png new file mode 100644 index 0000000..6262a9b Binary files /dev/null and b/attached_assets/image_1778586822613.png differ diff --git a/attached_assets/image_1778590645454.png b/attached_assets/image_1778590645454.png new file mode 100644 index 0000000..75a51e4 Binary files /dev/null and b/attached_assets/image_1778590645454.png differ diff --git a/attached_assets/image_1778590699186.png b/attached_assets/image_1778590699186.png new file mode 100644 index 0000000..98a7281 Binary files /dev/null and b/attached_assets/image_1778590699186.png differ diff --git a/attached_assets/image_1778591465964.png b/attached_assets/image_1778591465964.png new file mode 100644 index 0000000..c6de8df Binary files /dev/null and b/attached_assets/image_1778591465964.png differ diff --git a/attached_assets/image_1778592431807.png b/attached_assets/image_1778592431807.png new file mode 100644 index 0000000..a7574db Binary files /dev/null and b/attached_assets/image_1778592431807.png differ diff --git a/attached_assets/image_1778592535036.png b/attached_assets/image_1778592535036.png new file mode 100644 index 0000000..7c709f4 Binary files /dev/null and b/attached_assets/image_1778592535036.png differ diff --git a/attached_assets/image_1778592566732.png b/attached_assets/image_1778592566732.png new file mode 100644 index 0000000..5b19f48 Binary files /dev/null and b/attached_assets/image_1778592566732.png differ diff --git a/attached_assets/image_1778593987664.png b/attached_assets/image_1778593987664.png new file mode 100644 index 0000000..736e2bf Binary files /dev/null and b/attached_assets/image_1778593987664.png differ diff --git a/attached_assets/image_1778594776968.png b/attached_assets/image_1778594776968.png new file mode 100644 index 0000000..0fc3dcc Binary files /dev/null and b/attached_assets/image_1778594776968.png differ diff --git a/attached_assets/image_1778594810664.png b/attached_assets/image_1778594810664.png new file mode 100644 index 0000000..37fa93e Binary files /dev/null and b/attached_assets/image_1778594810664.png differ diff --git a/attached_assets/image_1778595364614.png b/attached_assets/image_1778595364614.png new file mode 100644 index 0000000..c274370 Binary files /dev/null and b/attached_assets/image_1778595364614.png differ diff --git a/attached_assets/image_1778595774870.png b/attached_assets/image_1778595774870.png new file mode 100644 index 0000000..17e0168 Binary files /dev/null and b/attached_assets/image_1778595774870.png differ diff --git a/attached_assets/image_1778595854727.png b/attached_assets/image_1778595854727.png new file mode 100644 index 0000000..cbd60af Binary files /dev/null and b/attached_assets/image_1778595854727.png differ diff --git a/attached_assets/image_1778600230843.png b/attached_assets/image_1778600230843.png new file mode 100644 index 0000000..3948e99 Binary files /dev/null and b/attached_assets/image_1778600230843.png differ diff --git a/attached_assets/image_1778600688715.png b/attached_assets/image_1778600688715.png new file mode 100644 index 0000000..db45b39 Binary files /dev/null and b/attached_assets/image_1778600688715.png differ diff --git a/attached_assets/image_1778600703870.png b/attached_assets/image_1778600703870.png new file mode 100644 index 0000000..83ee18f Binary files /dev/null and b/attached_assets/image_1778600703870.png differ diff --git a/attached_assets/image_1778606423193.png b/attached_assets/image_1778606423193.png new file mode 100644 index 0000000..db379c7 Binary files /dev/null and b/attached_assets/image_1778606423193.png differ diff --git a/attached_assets/image_1778606760197.png b/attached_assets/image_1778606760197.png new file mode 100644 index 0000000..59554fd Binary files /dev/null and b/attached_assets/image_1778606760197.png differ diff --git a/attached_assets/image_1778607654367.png b/attached_assets/image_1778607654367.png new file mode 100644 index 0000000..6962ff8 Binary files /dev/null and b/attached_assets/image_1778607654367.png differ diff --git a/attached_assets/image_1778608880689.png b/attached_assets/image_1778608880689.png new file mode 100644 index 0000000..9d9af67 Binary files /dev/null and b/attached_assets/image_1778608880689.png differ diff --git a/attached_assets/image_1778609753325.png b/attached_assets/image_1778609753325.png new file mode 100644 index 0000000..5e94315 Binary files /dev/null and b/attached_assets/image_1778609753325.png differ diff --git a/attached_assets/image_1778610558586.png b/attached_assets/image_1778610558586.png new file mode 100644 index 0000000..3751445 Binary files /dev/null and b/attached_assets/image_1778610558586.png differ diff --git a/attached_assets/image_1778610616301.png b/attached_assets/image_1778610616301.png new file mode 100644 index 0000000..4ee1ade Binary files /dev/null and b/attached_assets/image_1778610616301.png differ diff --git a/attached_assets/image_1778611887411.png b/attached_assets/image_1778611887411.png new file mode 100644 index 0000000..7363e29 Binary files /dev/null and b/attached_assets/image_1778611887411.png differ diff --git a/attached_assets/image_1778623868716.png b/attached_assets/image_1778623868716.png new file mode 100644 index 0000000..dcbf6e6 Binary files /dev/null and b/attached_assets/image_1778623868716.png differ diff --git a/attached_assets/image_1778624096991.png b/attached_assets/image_1778624096991.png new file mode 100644 index 0000000..93d2047 Binary files /dev/null and b/attached_assets/image_1778624096991.png differ diff --git a/attached_assets/image_1778624444385.png b/attached_assets/image_1778624444385.png new file mode 100644 index 0000000..59413cc Binary files /dev/null and b/attached_assets/image_1778624444385.png differ diff --git a/attached_assets/image_1778626016215.png b/attached_assets/image_1778626016215.png new file mode 100644 index 0000000..07c0693 Binary files /dev/null and b/attached_assets/image_1778626016215.png differ diff --git a/attached_assets/image_1778648470647.png b/attached_assets/image_1778648470647.png new file mode 100644 index 0000000..434ad16 Binary files /dev/null and b/attached_assets/image_1778648470647.png differ diff --git a/attached_assets/image_1778648666712.png b/attached_assets/image_1778648666712.png new file mode 100644 index 0000000..fa2aea9 Binary files /dev/null and b/attached_assets/image_1778648666712.png differ diff --git a/attached_assets/image_1778648914901.png b/attached_assets/image_1778648914901.png new file mode 100644 index 0000000..9544776 Binary files /dev/null and b/attached_assets/image_1778648914901.png differ diff --git a/attached_assets/image_1778669753613.png b/attached_assets/image_1778669753613.png new file mode 100644 index 0000000..d54bf56 Binary files /dev/null and b/attached_assets/image_1778669753613.png differ diff --git a/attached_assets/image_1778669757752.png b/attached_assets/image_1778669757752.png new file mode 100644 index 0000000..5a4651a Binary files /dev/null and b/attached_assets/image_1778669757752.png differ diff --git a/attached_assets/image_1778670743681.png b/attached_assets/image_1778670743681.png new file mode 100644 index 0000000..68ed779 Binary files /dev/null and b/attached_assets/image_1778670743681.png differ diff --git a/attached_assets/image_1778671336343.png b/attached_assets/image_1778671336343.png new file mode 100644 index 0000000..5deaea3 Binary files /dev/null and b/attached_assets/image_1778671336343.png differ diff --git a/attached_assets/image_1778674999143.png b/attached_assets/image_1778674999143.png new file mode 100644 index 0000000..59bc65f Binary files /dev/null and b/attached_assets/image_1778674999143.png differ diff --git a/attached_assets/image_1778676491844.png b/attached_assets/image_1778676491844.png new file mode 100644 index 0000000..04d1383 Binary files /dev/null and b/attached_assets/image_1778676491844.png differ diff --git a/attached_assets/image_1778676513413.png b/attached_assets/image_1778676513413.png new file mode 100644 index 0000000..0e61713 Binary files /dev/null and b/attached_assets/image_1778676513413.png differ diff --git a/attached_assets/image_1778681149585.png b/attached_assets/image_1778681149585.png new file mode 100644 index 0000000..3d6f7be Binary files /dev/null and b/attached_assets/image_1778681149585.png differ diff --git a/attached_assets/image_1778681240428.png b/attached_assets/image_1778681240428.png new file mode 100644 index 0000000..58cf751 Binary files /dev/null and b/attached_assets/image_1778681240428.png differ diff --git a/attached_assets/image_1778691517192.png b/attached_assets/image_1778691517192.png new file mode 100644 index 0000000..b2e4c51 Binary files /dev/null and b/attached_assets/image_1778691517192.png differ diff --git a/attached_assets/image_1778694903191.png b/attached_assets/image_1778694903191.png new file mode 100644 index 0000000..4143b4c Binary files /dev/null and b/attached_assets/image_1778694903191.png differ diff --git a/attached_assets/image_1778695939058.png b/attached_assets/image_1778695939058.png new file mode 100644 index 0000000..db7c4ac Binary files /dev/null and b/attached_assets/image_1778695939058.png differ diff --git a/attached_assets/image_1778696212905.png b/attached_assets/image_1778696212905.png new file mode 100644 index 0000000..84e9af3 Binary files /dev/null and b/attached_assets/image_1778696212905.png differ diff --git a/attached_assets/image_1778696681196.png b/attached_assets/image_1778696681196.png new file mode 100644 index 0000000..2472bb8 Binary files /dev/null and b/attached_assets/image_1778696681196.png differ diff --git a/attached_assets/image_1778696993763.png b/attached_assets/image_1778696993763.png new file mode 100644 index 0000000..31e80b4 Binary files /dev/null and b/attached_assets/image_1778696993763.png differ diff --git a/attached_assets/image_1778697551636.png b/attached_assets/image_1778697551636.png new file mode 100644 index 0000000..fa1d381 Binary files /dev/null and b/attached_assets/image_1778697551636.png differ diff --git a/attached_assets/image_1778697665586.png b/attached_assets/image_1778697665586.png new file mode 100644 index 0000000..3094717 Binary files /dev/null and b/attached_assets/image_1778697665586.png differ diff --git a/attached_assets/image_1778698422538.png b/attached_assets/image_1778698422538.png new file mode 100644 index 0000000..8efae19 Binary files /dev/null and b/attached_assets/image_1778698422538.png differ diff --git a/attached_assets/image_1778700593009.png b/attached_assets/image_1778700593009.png new file mode 100644 index 0000000..0560d82 Binary files /dev/null and b/attached_assets/image_1778700593009.png differ diff --git a/attached_assets/targeted_element_1758143364853.png b/attached_assets/targeted_element_1758143364853.png new file mode 100644 index 0000000..e773ecc Binary files /dev/null and b/attached_assets/targeted_element_1758143364853.png differ diff --git a/attached_assets/Авто_для_путевых_1765303798477.xlsx b/attached_assets/Авто_для_путевых_1765303798477.xlsx new file mode 100644 index 0000000..c0bfe2b Binary files /dev/null and b/attached_assets/Авто_для_путевых_1765303798477.xlsx differ diff --git a/attached_assets/Авто_для_путевых_1765307172207.xlsx b/attached_assets/Авто_для_путевых_1765307172207.xlsx new file mode 100644 index 0000000..c0bfe2b Binary files /dev/null and b/attached_assets/Авто_для_путевых_1765307172207.xlsx differ diff --git a/attached_assets/Авто_для_путевых_1765311458389.xlsx b/attached_assets/Авто_для_путевых_1765311458389.xlsx new file mode 100644 index 0000000..eeac8a2 Binary files /dev/null and b/attached_assets/Авто_для_путевых_1765311458389.xlsx differ diff --git a/attached_assets/код_1764615965252.txt b/attached_assets/код_1764615965252.txt new file mode 100644 index 0000000..d8f939d --- /dev/null +++ b/attached_assets/код_1764615965252.txt @@ -0,0 +1,91 @@ +[ + { + "headers": { + "host": "phesieroci.beget.app", + "user-agent": "node", + "content-length": "1264", + "accept": "*/*", + "accept-encoding": "br, gzip, deflate", + "accept-language": "*", + "content-type": "application/json", + "sec-fetch-mode": "cors", + "x-bot-id": "2", + "x-forwarded-for": "34.26.110.219", + "x-forwarded-host": "phesieroci.beget.app", + "x-forwarded-port": "443", + "x-forwarded-proto": "https", + "x-forwarded-server": "ff379305669f", + "x-organization-id": "14", + "x-real-ip": "34.26.110.219" + }, + "params": {}, + "query": {}, + "body": { + "event": "bot_mentioned", + "timestamp": "2025-12-01T19:04:42.587Z", + "organizationId": 14, + "data": { + "task": { + "id": 10, + "formId": 8, + "title": "всвммц", + "description": "", + "currentStatusId": 11, + "assignedTo": null, + "createdBy": 7, + "createdAt": "2025-09-17T13:21:41.540Z", + "updatedAt": "2025-11-20T13:21:42.570Z", + "completedAt": "2025-11-21T06:30:00.751Z", + "isCompleted": true, + "dueDate": null, + "fieldValues": [ + { + "id": 9, + "taskId": 10, + "fieldId": 18, + "formId": 8, + "value": "ссвцмм", + "createdAt": "2025-09-17T13:21:41.691Z", + "updatedAt": "2025-09-17T13:21:41.691Z" + }, + { + "id": 10, + "taskId": 10, + "fieldId": 19, + "formId": 8, + "value": 1111, + "createdAt": "2025-09-17T13:21:41.772Z", + "updatedAt": "2025-09-17T13:21:41.772Z" + }, + { + "id": 11, + "taskId": 10, + "fieldId": 20, + "formId": 8, + "value": "2025-09-17", + "createdAt": "2025-09-17T13:21:41.846Z", + "updatedAt": "2025-09-17T13:21:41.846Z" + } + ] + }, + "form": { + "id": 8, + "name": "1" + }, + "message": { + "id": 210, + "text": "@второй нах ", + "authorId": 7, + "authorName": "Ильяс Султанов" + }, + "bot": { + "id": 2, + "name": "второй нах", + "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJib3RJZCI6Miwib3JnYW5pemF0aW9uSWQiOjE0LCJ0eXBlIjoiYm90X3NlcnZpY2UiLCJpYXQiOjE3NjQ2MTU4ODIsImV4cCI6MTc2NDYxNjQ4MiwiYXVkIjoid29ya2Zsb3ctYm90cyIsImlzcyI6IndvcmtmbG93LXN5c3RlbSJ9.wYGoc2F2Jz8k-YerYdS0WGCLAh_OQrdM-Al29QqZzCA" + } + } + }, + "webhookUrl": "https://phesieroci.beget.app/webhook-test/28ec909a-8c38-493c-9e99-9765261996e3", + "executionMode": "test" + } +] \ No newline at end of file diff --git a/attached_assets/код_1775994765939.json b/attached_assets/код_1775994765939.json new file mode 100644 index 0000000..419c6b6 --- /dev/null +++ b/attached_assets/код_1775994765939.json @@ -0,0 +1,919 @@ +const React = ctx.libs.React; +const { useState, useEffect, useMemo, useCallback, useRef } = React; +const { + Card, + Typography, + Spin, + Input, + Button, + Empty, + Dropdown, + Modal, + Row, + Col, + Tag, + Space, + Grid, +} = ctx.libs.antd; +const { MoreOutlined } = ctx.libs.antdIcons; + +const { Title, Text } = Typography; +const { useBreakpoint } = Grid; +const TASK_ID = 1; + +const THEME = { + bg: '#17191d', + bg2: '#1d2025', + panel: '#1f2329', + panel2: '#242931', + panel3: '#2a3038', + border: '#343b46', + text: '#eef2f6', + muted: '#9aa4b2', + faint: '#768191', + accent: '#27c2b1', + accent2: '#2f7cf6', + accentSoft: 'rgba(39,194,177,.12)', + msgBg: '#2a2e35', + msgBgAlt: '#252a31', + quoteBg: '#21262d', + danger: '#ff7373', + inputBg: '#1b1f25', + top: '#1b1e23', +}; + +const STATUS = { + new: { label: 'Новая', color: '#8b949e' }, + in_progress: { label: 'В работе', color: '#2f81f7' }, + done: { label: 'Выполнена', color: '#3fb950' }, + cancelled: { label: 'Отменена', color: '#f85149' }, +}; + +const URGENCY = { + today: { label: 'Сегодня', color: '#f85149' }, + tomorrow: { label: 'Завтра', color: '#d29922' }, + later: { label: 'Позже', color: '#2f81f7' }, +}; + +function getCurrentUserId() { + try { + return Number(ctx?.auth?.user?.id || 1); + } catch (e) { + return 1; + } +} + +function getUserLabel(id) { + return `User #${id || '?'}`; +} + +function formatDateTime(value) { + if (!value) return ''; + try { + return new Date(value).toLocaleString('ru-RU', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); + } catch (e) { + return String(value); + } +} + +function formatClock(value) { + if (!value) return ''; + try { + return new Date(value).toLocaleTimeString('ru-RU', { + hour: '2-digit', + minute: '2-digit', + }); + } catch (e) { + return ''; + } +} + +function formatDayLabel(value) { + if (!value) return ''; + const d = new Date(value); + const today = new Date(); + const yesterday = new Date(); + yesterday.setDate(today.getDate() - 1); + + const key = (x) => `${x.getFullYear()}-${x.getMonth()}-${x.getDate()}`; + if (key(d) === key(today)) return 'Сегодня'; + if (key(d) === key(yesterday)) return 'Вчера'; + return d.toLocaleDateString('ru-RU'); +} + +function sortFlat(list) { + return [...(Array.isArray(list) ? list : [])].sort( + (a, b) => new Date(a.createdAt) - new Date(b.createdAt) + ); +} + +function avatarColor(id) { + const colors = ['#b45309', '#7c3aed', '#0ea5e9', '#15803d', '#db2777', '#ea580c']; + return colors[Math.abs(Number(id || 0)) % colors.length]; +} + +function Avatar({ userId, size = 34 }) { + return React.createElement('div', { + style: { + width: size, + height: size, + borderRadius: 10, + background: avatarColor(userId), + color: '#fff', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + fontSize: 13, + fontWeight: 700, + flexShrink: 0, + } + }, String(userId || '?')); +} + +function InfoRow({ label, value }) { + return React.createElement('div', { + style: { + display: 'grid', + gridTemplateColumns: '110px 1fr', + gap: 10, + padding: '10px 0', + borderBottom: `1px solid ${THEME.border}`, + alignItems: 'start', + } + }, + React.createElement('div', { + style: { + color: THEME.faint, + fontSize: 12, + textTransform: 'uppercase', + letterSpacing: '.04em', + fontWeight: 700, + } + }, label), + React.createElement('div', { + style: { + color: THEME.text, + fontSize: 14, + minWidth: 0, + wordBreak: 'break-word', + } + }, value || '—') + ); +} + +function App() { + const screens = useBreakpoint(); + const isMobile = !screens.lg; + + const [task, setTask] = useState(null); + const [comments, setComments] = useState([]); + const [loadingTask, setLoadingTask] = useState(true); + const [loadingComments, setLoadingComments] = useState(true); + const [posting, setPosting] = useState(false); + const [savingEdit, setSavingEdit] = useState(false); + const [text, setText] = useState(''); + const [replyTo, setReplyTo] = useState(null); + const [editing, setEditing] = useState(null); + const [editText, setEditText] = useState(''); + const [allowedDestroyIds, setAllowedDestroyIds] = useState([]); + const [highlightId, setHighlightId] = useState(null); + const [activeTab, setActiveTab] = useState('info'); + + const listRef = useRef(null); + const messageRefs = useRef({}); + const previousCountRef = useRef(0); + const currentUserId = getCurrentUserId(); + + const loadTask = useCallback(async () => { + setLoadingTask(true); + try { + const resp = await ctx.request({ + url: 'tasks:get', + params: { filterByTk: TASK_ID } + }); + setTask(resp?.data?.data || null); + } catch (e) { + console.error(e); + ctx.message.error('Не удалось загрузить задачу'); + } finally { + setLoadingTask(false); + } + }, []); + + const loadComments = useCallback(async () => { + setLoadingComments(true); + try { + const resp = await ctx.request({ + url: `tasks/${TASK_ID}/comments:list`, + params: { + pageSize: 300, + sort: ['createdAt'] + } + }); + const rows = Array.isArray(resp?.data?.data) ? resp.data.data : []; + const destroyIds = resp?.data?.meta?.allowedActions?.destroy || []; + setComments(rows); + setAllowedDestroyIds(Array.isArray(destroyIds) ? destroyIds : []); + } catch (e) { + console.error(e); + ctx.message.error('Не удалось загрузить комментарии'); + } finally { + setLoadingComments(false); + } + }, []); + + useEffect(() => { + loadTask(); + loadComments(); + }, []); + + const sortedComments = useMemo(() => sortFlat(comments), [comments]); + + const commentsMap = useMemo(() => { + const map = new Map(); + comments.forEach((item) => map.set(item.id, item)); + return map; + }, [comments]); + + const commentBlocks = useMemo(() => { + const blocks = []; + let lastDay = null; + + sortedComments.forEach((item) => { + const day = formatDayLabel(item.createdAt); + if (day !== lastDay) { + blocks.push({ type: 'day', key: `day-${day}-${item.id}`, label: day }); + lastDay = day; + } + blocks.push({ type: 'message', key: `msg-${item.id}`, item }); + }); + + return blocks; + }, [sortedComments]); + + useEffect(() => { + const prevCount = previousCountRef.current; + const hasNewMessages = comments.length > prevCount; + previousCountRef.current = comments.length; + + if (!listRef.current || !hasNewMessages) return; + + const timer = setTimeout(() => { + if (listRef.current) listRef.current.scrollTop = listRef.current.scrollHeight; + }, 0); + + return () => clearTimeout(timer); + }, [comments.length]); + + useEffect(() => { + if (!highlightId) return; + const timer = setTimeout(() => setHighlightId(null), 1800); + return () => clearTimeout(timer); + }, [highlightId]); + + const jumpToMessage = (id) => { + const node = messageRefs.current[id]; + if (node && typeof node.scrollIntoView === 'function') { + node.scrollIntoView({ behavior: 'smooth', block: 'center' }); + setHighlightId(id); + } + }; + + const submit = async () => { + if (!text.trim()) return; + setPosting(true); + try { + await ctx.request({ + url: `tasks/${TASK_ID}/comments:create`, + method: 'post', + data: { + content: text.trim(), + task_id: TASK_ID, + parentId: replyTo?.id ?? null, + } + }); + setText(''); + setReplyTo(null); + await loadComments(); + ctx.message.success('Комментарий отправлен'); + } catch (e) { + console.error(e); + ctx.message.error('Не удалось отправить комментарий'); + } finally { + setPosting(false); + } + }; + + const openEdit = (item) => { + setEditing(item); + setEditText(item?.content || ''); + }; + + const updateComment = async () => { + if (!editing?.id || !editText.trim()) return; + setSavingEdit(true); + try { + await ctx.request({ + url: `tasks/${TASK_ID}/comments:update`, + method: 'post', + params: { filterByTk: editing.id }, + data: { content: editText.trim() } + }); + setEditing(null); + setEditText(''); + await loadComments(); + ctx.message.success('Комментарий изменён'); + } catch (e) { + console.error(e); + ctx.message.error('Не удалось изменить комментарий'); + } finally { + setSavingEdit(false); + } + }; + + const removeComment = async (item) => { + try { + await ctx.request({ + url: `tasks/${TASK_ID}/comments:destroy`, + method: 'post', + params: { filterByTk: item.id } + }); + await loadComments(); + ctx.message.success('Комментарий удалён'); + } catch (e) { + console.error(e); + ctx.message.error('Не удалось удалить комментарий'); + } + }; + + const renderMessage = (item, index) => { + const parent = item.parentId ? commentsMap.get(item.parentId) : null; + const canDelete = allowedDestroyIds.includes(item.id) || allowedDestroyIds.length > 0; + const canEdit = Number(item.createdById) === Number(currentUserId); + + const menuItems = [ + { key: 'reply', label: 'Ответить' }, + { key: 'edit', label: 'Изменить', disabled: !canEdit }, + { + key: 'delete', + label: React.createElement('span', { style: { color: THEME.danger } }, 'Удалить'), + disabled: !canDelete, + }, + ]; + + return React.createElement('div', { + key: item.id, + ref: (el) => { messageRefs.current[item.id] = el; }, + style: { + display: 'flex', + alignItems: 'flex-start', + gap: isMobile ? 10 : 12, + padding: isMobile ? '12px' : '14px 16px', + background: index % 2 === 0 ? THEME.msgBg : THEME.msgBgAlt, + borderTop: `1px solid rgba(255,255,255,.03)`, + outline: highlightId === item.id ? '1px solid rgba(39,194,177,.75)' : '1px solid transparent', + } + }, + React.createElement(Avatar, { userId: item.createdById, size: isMobile ? 30 : 34 }), + + React.createElement('div', { style: { flex: 1, minWidth: 0 } }, + React.createElement('div', { + style: { + display: 'flex', + alignItems: 'flex-start', + justifyContent: 'space-between', + gap: 8, + marginBottom: 6, + } + }, + React.createElement('div', { + style: { + display: 'flex', + alignItems: 'center', + gap: 8, + flexWrap: 'wrap', + minWidth: 0, + } + }, + React.createElement('span', { + style: { + color: THEME.text, + fontWeight: 700, + fontSize: isMobile ? 14 : 15, + } + }, getUserLabel(item.createdById)), + React.createElement('span', { + style: { + color: THEME.muted, + fontSize: 12, + }, + title: formatDateTime(item.createdAt) + }, formatClock(item.createdAt)) + ), + + React.createElement(Dropdown, { + trigger: ['click'], + placement: 'bottomRight', + menu: { + items: menuItems, + onClick: ({ key }) => { + if (key === 'reply') setReplyTo(item); + if (key === 'edit') openEdit(item); + if (key === 'delete') removeComment(item); + }, + }, + }, + React.createElement(Button, { + type: 'text', + size: 'small', + icon: React.createElement(MoreOutlined), + style: { + color: THEME.muted, + minWidth: 36, + height: 36, + borderRadius: 10, + } + }) + ) + ), + + parent && React.createElement('button', { + onClick: () => jumpToMessage(parent.id), + style: { + display: 'block', + width: '100%', + textAlign: 'left', + border: 'none', + background: THEME.quoteBg, + borderLeft: `3px solid ${THEME.accent2}`, + padding: '8px 10px', + marginBottom: 10, + borderRadius: 8, + cursor: 'pointer', + } + }, + React.createElement('div', { + style: { + color: THEME.accent2, + fontWeight: 700, + fontSize: 13, + marginBottom: 2, + } + }, getUserLabel(parent.createdById)), + React.createElement('div', { + style: { + color: THEME.muted, + fontSize: 13, + whiteSpace: 'pre-wrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + display: '-webkit-box', + WebkitLineClamp: 2, + WebkitBoxOrient: 'vertical', + } + }, parent.content || '') + ), + + React.createElement('div', { + style: { + color: THEME.text, + fontSize: isMobile ? 15 : 16, + lineHeight: 1.55, + whiteSpace: 'pre-wrap', + wordBreak: 'break-word', + } + }, item.content || '') + ) + ); + }; + + const taskStatus = STATUS[task?.status]; + const taskUrgency = URGENCY[task?.urgency]; + + const renderSideContent = () => { + if (loadingTask) return React.createElement(Spin); + if (!task) return React.createElement(Empty, { description: 'Задача не найдена' }); + + if (activeTab === 'info') { + return React.createElement('div', null, + React.createElement(InfoRow, { label: 'ID', value: String(task.id || TASK_ID) }), + React.createElement(InfoRow, { label: 'Задача', value: task.what_to_do || '—' }), + React.createElement(InfoRow, { label: 'Статус', value: taskStatus?.label || task.status || '—' }), + React.createElement(InfoRow, { label: 'Срочность', value: taskUrgency?.label || task.urgency || '—' }), + React.createElement(InfoRow, { label: 'Срок', value: task.deadline ? formatDateTime(task.deadline) : '—' }), + React.createElement(InfoRow, { label: 'Создана', value: task.createdAt ? formatDateTime(task.createdAt) : '—' }), + React.createElement(InfoRow, { label: 'Комментариев', value: String(comments.length) }) + ); + } + + if (activeTab === 'related') { + return React.createElement('div', { + style: { color: THEME.muted, fontSize: 14, lineHeight: 1.6 } + }, 'Здесь можно будет показать связанные записи, подзадачи, ссылки на объекты и связи задачи. Сейчас вкладка подготовлена как контейнер под дальнейшую интеграцию.'); + } + + if (activeTab === 'files') { + return React.createElement('div', { + style: { color: THEME.muted, fontSize: 14, lineHeight: 1.6 } + }, 'Здесь можно будет вывести вложения задачи и файловые записи. Сейчас вкладка готова как UX-каркас под файлы и действия с ними.'); + } + + return null; + }; + + const tabButton = (key, label) => React.createElement('button', { + onClick: () => setActiveTab(key), + style: { + minHeight: 42, + padding: '10px 12px', + border: 'none', + cursor: 'pointer', + background: activeTab === key ? THEME.panel3 : 'transparent', + color: activeTab === key ? THEME.text : THEME.muted, + borderBottom: !isMobile && activeTab === key ? `2px solid ${THEME.accent}` : '2px solid transparent', + borderRadius: isMobile ? 10 : 0, + fontSize: 13, + fontWeight: 700, + whiteSpace: 'nowrap', + } + }, label); + + return React.createElement('div', { + style: { + background: THEME.bg, + minHeight: '100vh', + padding: isMobile ? 8 : 12, + } + }, + React.createElement(Card, { + style: { + background: THEME.panel, + borderColor: THEME.border, + borderRadius: isMobile ? 12 : 14, + marginBottom: 12, + }, + bodyStyle: { padding: isMobile ? 10 : 12 } + }, + React.createElement('div', { + style: { + display: 'flex', + flexDirection: isMobile ? 'column' : 'row', + alignItems: isMobile ? 'stretch' : 'center', + justifyContent: 'space-between', + gap: 10, + } + }, + React.createElement('div', null, + React.createElement('div', { + style: { + color: THEME.text, + fontSize: isMobile ? 20 : 24, + fontWeight: 700, + lineHeight: 1.2, + } + }, task?.what_to_do || `Задача #${TASK_ID}`), + React.createElement('div', { + style: { + display: 'flex', + gap: 8, + flexWrap: 'wrap', + marginTop: 8, + color: THEME.muted, + fontSize: 12, + } + }, + taskStatus && React.createElement(Tag, { color: taskStatus.color }, taskStatus.label), + taskUrgency && React.createElement(Tag, { color: taskUrgency.color }, taskUrgency.label), + React.createElement('span', null, `${comments.length} комментариев`) + ) + ), + + React.createElement('div', { + style: { + display: 'flex', + gap: 8, + flexWrap: 'wrap', + } + }, + React.createElement(Button, { + style: { + minHeight: 40, + borderRadius: 10, + background: THEME.panel2, + color: THEME.text, + borderColor: THEME.border, + } + }, 'Назад'), + React.createElement(Button, { + style: { + minHeight: 40, + borderRadius: 10, + background: THEME.panel2, + color: THEME.text, + borderColor: THEME.border, + } + }, 'Действия'), + React.createElement(Button, { + type: 'primary', + style: { + minHeight: 40, + borderRadius: 10, + background: THEME.accent, + borderColor: THEME.accent, + color: '#08201d', + boxShadow: 'none', + } + }, 'Утвердить') + ) + ) + ), + + {isMobile + ? React.createElement('div', { + style: { + display: 'flex', + gap: 8, + overflowX: 'auto', + paddingBottom: 8, + marginBottom: 10, + } + }, + tabButton('info', 'Инфо'), + tabButton('related', 'Связанные'), + tabButton('files', 'Файлы') + ) + : null} + + ,React.createElement(Row, { gutter: isMobile ? 0 : 16, align: 'stretch' }, + React.createElement(Col, { xs: 24, lg: 15 }, + React.createElement(Card, { + style: { + background: THEME.panel, + borderColor: THEME.border, + borderRadius: isMobile ? 12 : 14, + overflow: 'hidden', + marginBottom: isMobile ? 12 : 0, + }, + bodyStyle: { padding: 0 } + }, + React.createElement('div', { + style: { + padding: isMobile ? '10px 12px' : '12px 16px', + borderBottom: `1px solid ${THEME.border}`, + background: THEME.top, + position: 'sticky', + top: 0, + zIndex: 2, + } + }, + React.createElement('div', { + style: { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + gap: 10, + } + }, + React.createElement('div', { + style: { color: THEME.text, fontWeight: 700, fontSize: 15 } + }, 'Обсуждение'), + React.createElement(Text, { + style: { color: THEME.muted, fontSize: 12 } + }, `${comments.length} сообщений`) + ) + ), + + React.createElement('div', { + ref: listRef, + style: { + maxHeight: isMobile ? '55vh' : '62vh', + overflowY: 'auto', + background: THEME.panel2, + } + }, + loadingComments + ? React.createElement('div', { style: { padding: 16 } }, React.createElement(Spin)) + : commentBlocks.length === 0 + ? React.createElement('div', { style: { padding: 16 } }, React.createElement(Empty, { description: 'Комментариев пока нет' })) + : commentBlocks.map((block, idx) => { + if (block.type === 'day') { + return React.createElement('div', { + key: block.key, + style: { + display: 'flex', + justifyContent: 'center', + padding: '12px 10px 8px', + background: THEME.panel2, + } + }, + React.createElement('span', { + style: { + background: THEME.panel3, + color: THEME.muted, + fontSize: 12, + padding: '4px 10px', + borderRadius: 999, + border: `1px solid ${THEME.border}`, + } + }, block.label) + ); + } + return renderMessage(block.item, idx); + }) + ), + + React.createElement('div', { + style: { + padding: isMobile ? 12 : 16, + borderTop: `1px solid ${THEME.border}`, + background: THEME.panel, + position: 'sticky', + bottom: 0, + zIndex: 2, + } + }, + replyTo && React.createElement('div', { + style: { + marginBottom: 10, + padding: '8px 10px', + borderLeft: `3px solid ${THEME.accent}`, + background: THEME.accentSoft, + borderRadius: 8, + display: 'flex', + justifyContent: 'space-between', + gap: 10, + } + }, + React.createElement('button', { + onClick: () => jumpToMessage(replyTo.id), + style: { + flex: 1, + textAlign: 'left', + border: 'none', + background: 'transparent', + cursor: 'pointer', + padding: 0, + } + }, + React.createElement('div', { + style: { color: THEME.accent, fontWeight: 700, fontSize: 13, marginBottom: 2 } + }, getUserLabel(replyTo.createdById)), + React.createElement('div', { + style: { + color: THEME.muted, + fontSize: 13, + whiteSpace: 'pre-wrap', + maxHeight: 36, + overflow: 'hidden', + } + }, replyTo.content || '') + ), + React.createElement(Button, { + type: 'text', + size: 'small', + onClick: () => setReplyTo(null), + style: { + color: THEME.muted, + minWidth: 36, + height: 36, + } + }, '✕') + ), + + React.createElement('div', { + style: { + display: 'flex', + gap: 10, + alignItems: 'flex-start', + } + }, + !isMobile && React.createElement(Avatar, { userId: currentUserId, size: 32 }), + React.createElement('div', { style: { flex: 1 } }, + React.createElement(Input.TextArea, { + value: text, + onChange: (e) => setText(e.target.value), + placeholder: isMobile ? 'Комментарий...' : 'Написать комментарий...', + autoSize: { minRows: isMobile ? 3 : 4, maxRows: 8 }, + style: { + background: THEME.inputBg, + color: THEME.text, + borderColor: THEME.border, + borderRadius: 12, + resize: 'none', + fontSize: 16, + }, + onKeyDown: (e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + submit(); + } + } + }), + React.createElement('div', { + style: { + marginTop: 10, + display: 'flex', + flexDirection: isMobile ? 'column' : 'row', + justifyContent: 'space-between', + alignItems: isMobile ? 'stretch' : 'center', + gap: 10, + } + }, + React.createElement(Text, { + style: { fontSize: 12, color: THEME.muted } + }, isMobile ? 'Enter — отправить' : 'Enter — отправить, Shift + Enter — новая строка'), + React.createElement(Button, { + type: 'primary', + loading: posting, + disabled: !text.trim(), + onClick: submit, + block: isMobile, + style: { + minHeight: 44, + borderRadius: 12, + background: THEME.accent, + borderColor: THEME.accent, + color: '#08201d', + boxShadow: 'none', + fontWeight: 700, + } + }, replyTo ? 'Ответить' : 'Отправить') + ) + ) + ) + ) + ) + ), + + !isMobile && React.createElement(Col, { xs: 24, lg: 9 }, + React.createElement(Card, { + style: { + background: THEME.panel, + borderColor: THEME.border, + borderRadius: 14, + height: '100%', + }, + bodyStyle: { padding: 0 } + }, + React.createElement('div', { + style: { + display: 'flex', + borderBottom: `1px solid ${THEME.border}`, + paddingInline: 8, + gap: 2, + } + }, + tabButton('info', 'Инфо'), + tabButton('related', 'Связанные'), + tabButton('files', 'Файлы') + ), + React.createElement('div', { + style: { + padding: 16, + minHeight: 420, + } + }, renderSideContent()) + ) + ) + ), + + {isMobile + ? React.createElement(Card, { + style: { + background: THEME.panel, + borderColor: THEME.border, + borderRadius: 12, + }, + bodyStyle: { padding: 14 } + }, renderSideContent()) + : null} + + ,React.createElement(Modal, { + open: !!editing, + title: 'Изменить комментарий', + onCancel: () => { + setEditing(null); + setEditText(''); + }, + onOk: updateComment, + okText: 'Сохранить', + cancelText: 'Отмена', + confirmLoading: savingEdit, + }, + React.createElement(Input.TextArea, { + value: editText, + onChange: (e) => setEditText(e.target.value), + autoSize: { minRows: 4, maxRows: 10 }, + placeholder: 'Новый текст комментария...' + }) + ) + ); +} + +ctx.render(React.createElement(App)); \ No newline at end of file diff --git a/attached_assets/пример_2_1767959300484.json b/attached_assets/пример_2_1767959300484.json new file mode 100644 index 0000000..fef7f61 --- /dev/null +++ b/attached_assets/пример_2_1767959300484.json @@ -0,0 +1,74 @@ +{ + "type": "2", + "label": "Google Таблицы (вкладки)", + "config": { + "title": "Множественные Google Таблицы", + "hideTitle": true, + "componentType": "tabs", + "tabsContainerStyle": { + "margin": "0", + "padding": "0", + "width": "100%", + "height": "100%" + }, + "tabContentStyle": { + "margin": "0", + "padding": "0", + "overflow": "hidden" + } + }, + "inputSchema": [ + { + "name": "tabs", + "type": "array", + "label": "Вкладки таблиц", + "default": [ + { + "name": "Таблица 1", + "embedUrl": "", + "height": 500 + } + ], + "items": { + "type": "object", + "properties": [ + { + "name": "name", + "type": "string", + "label": "Название вкладки", + "default": "Таблица" + }, + { + "name": "embedUrl", + "type": "url", + "label": "URL таблицы", + "required": true, + "placeholder": "https://docs.google.com/spreadsheets/d/..." + }, + { + "name": "height", + "type": "number", + "label": "Высота (px)", + "default": 500, + "min": 200, + "max": 2000 + } + ] + } + } + ], + "renderTemplate": { + "html": "
    " + + "
    " + + " {{tabs.map((tab,i) => `
    ${tab.name}
    `).join('')}}" + + "
    + Добавить
    " + + "
    " + + "
    " + + " {{tabs.map((tab,i) => ``).join('')}}" + + "
    ", + "css": ".tabs-container .tab-btn.active{background:#fff;color:#333;font-weight:500;box-shadow:inset 0 -2px 0 #1976d2;}.tabs-container .tab-btn:hover{background:#e3f2fd;}.tabs-container .add-tab-btn:hover{background:#f0f0f0;}.tabs-container .tab-content.active{display:block;}", + "js": "let activeTab = 0; let tabs = {{tabs}}; document.querySelectorAll('.tab-btn').forEach((btn,i)=>{btn.onclick=()=>{activeTab=i;updateTabs();}});document.querySelector('.add-tab-btn').onclick=()=>{tabs.push({name:`Таблица ${tabs.length+1}`,embedUrl:'',height:500});renderModule();};function updateTabs(){document.querySelectorAll('.tab-btn, .tab-content').forEach((el,i)=>{el.classList.toggle('active',i===activeTab);});}" + } +} diff --git a/capacitor.config.ts b/capacitor.config.ts new file mode 100644 index 0000000..c52b300 --- /dev/null +++ b/capacitor.config.ts @@ -0,0 +1,18 @@ +import type { CapacitorConfig } from '@capacitor/cli'; + +const config: CapacitorConfig = { + appId: 'com.iistwin.app', + appName: 'iistwin', + webDir: 'dist/public', + server: { + androidScheme: 'https', + iosScheme: 'https', + }, + plugins: { + PushNotifications: { + presentationOptions: ['badge', 'sound', 'alert'], + }, + }, +}; + +export default config; diff --git a/client/index.html b/client/index.html new file mode 100644 index 0000000..3bfdda7 --- /dev/null +++ b/client/index.html @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + iistwin + + + + + +
    + + + \ No newline at end of file diff --git a/client/public/favicon.svg b/client/public/favicon.svg new file mode 100644 index 0000000..8aa3a17 --- /dev/null +++ b/client/public/favicon.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/client/public/icons/icon-128x128.svg b/client/public/icons/icon-128x128.svg new file mode 100644 index 0000000..0020efd --- /dev/null +++ b/client/public/icons/icon-128x128.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/client/public/icons/icon-144x144.svg b/client/public/icons/icon-144x144.svg new file mode 100644 index 0000000..bfe8876 --- /dev/null +++ b/client/public/icons/icon-144x144.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/client/public/icons/icon-152x152.svg b/client/public/icons/icon-152x152.svg new file mode 100644 index 0000000..8fa08ea --- /dev/null +++ b/client/public/icons/icon-152x152.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/client/public/icons/icon-192x192.png b/client/public/icons/icon-192x192.png new file mode 100644 index 0000000..b1f8bea Binary files /dev/null and b/client/public/icons/icon-192x192.png differ diff --git a/client/public/icons/icon-192x192.svg b/client/public/icons/icon-192x192.svg new file mode 100644 index 0000000..ed5f659 --- /dev/null +++ b/client/public/icons/icon-192x192.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/client/public/icons/icon-384x384.svg b/client/public/icons/icon-384x384.svg new file mode 100644 index 0000000..ceb1a16 --- /dev/null +++ b/client/public/icons/icon-384x384.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/client/public/icons/icon-512x512.png b/client/public/icons/icon-512x512.png new file mode 100644 index 0000000..ee5144f Binary files /dev/null and b/client/public/icons/icon-512x512.png differ diff --git a/client/public/icons/icon-512x512.svg b/client/public/icons/icon-512x512.svg new file mode 100644 index 0000000..47b430a --- /dev/null +++ b/client/public/icons/icon-512x512.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/client/public/icons/icon-72x72.svg b/client/public/icons/icon-72x72.svg new file mode 100644 index 0000000..c12ef20 --- /dev/null +++ b/client/public/icons/icon-72x72.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/client/public/icons/icon-96x96.svg b/client/public/icons/icon-96x96.svg new file mode 100644 index 0000000..f923756 --- /dev/null +++ b/client/public/icons/icon-96x96.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/client/public/manifest.json b/client/public/manifest.json new file mode 100644 index 0000000..f81a8fa --- /dev/null +++ b/client/public/manifest.json @@ -0,0 +1,80 @@ +{ + "id": "/", + "name": "iistwin", + "short_name": "iistwin", + "description": "Корпоративная платформа iistwin", + "start_url": "/home", + "scope": "/", + "display": "standalone", + "background_color": "#09090b", + "theme_color": "#09090b", + "orientation": "portrait-primary", + "icons": [ + { + "src": "/icons/icon-72x72.svg", + "sizes": "72x72", + "type": "image/svg+xml", + "purpose": "any" + }, + { + "src": "/icons/icon-96x96.svg", + "sizes": "96x96", + "type": "image/svg+xml", + "purpose": "any" + }, + { + "src": "/icons/icon-128x128.svg", + "sizes": "128x128", + "type": "image/svg+xml", + "purpose": "any" + }, + { + "src": "/icons/icon-144x144.svg", + "sizes": "144x144", + "type": "image/svg+xml", + "purpose": "any" + }, + { + "src": "/icons/icon-152x152.svg", + "sizes": "152x152", + "type": "image/svg+xml", + "purpose": "any" + }, + { + "src": "/icons/icon-192x192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "/icons/icon-384x384.svg", + "sizes": "384x384", + "type": "image/svg+xml", + "purpose": "any" + }, + { + "src": "/icons/icon-512x512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any maskable" + } + ], + "categories": ["business", "productivity"], + "prefer_related_applications": false, + "share_target": { + "action": "/share-target", + "method": "POST", + "enctype": "multipart/form-data", + "params": { + "title": "title", + "text": "text", + "url": "url", + "files": [ + { + "name": "media", + "accept": ["image/*", "application/pdf", ".doc", ".docx", ".xls", ".xlsx", ".txt", ".zip"] + } + ] + } + } +} diff --git a/client/public/sw.js b/client/public/sw.js new file mode 100644 index 0000000..dc445e0 --- /dev/null +++ b/client/public/sw.js @@ -0,0 +1,624 @@ +const CACHE_NAME = 'iistwin-pwa-v10'; +const STATIC_ASSETS = [ + '/', + '/index.html', + '/manifest.json', + '/favicon.svg' +]; + +// ── IndexedDB helpers (mirrors offlineQueue.ts schema, must stay in sync) ──── +// Schema history: +// v1 – `queue` store (keyPath: id, index: enqueuedAt) +// v2 – no structural change (conflictStatus field added to queue items) +// v3 – `inbox-cache`, `task-cache`, `form-cache` added for PWA offline mode +// v4 – `directory-cache` added for offline справочник lookups +// v5 – `attachment-queue` для фоновой отправки сообщений с файлами +// v6 – synced-* stores для офлайн-кэша форм/задач/справочников +// v7 – queue token/orgId fields nullable for cookie-based auth +const IDB_NAME = 'iistwin-offline'; +const IDB_VERSION = 7; +const IDB_STORE = 'queue'; + +function openQueueDB() { + return new Promise((resolve, reject) => { + const req = indexedDB.open(IDB_NAME, IDB_VERSION); + req.onupgradeneeded = (e) => { + const db = e.target.result; + // v1 + if (!db.objectStoreNames.contains('queue')) { + const store = db.createObjectStore('queue', { keyPath: 'id' }); + store.createIndex('enqueuedAt', 'enqueuedAt', { unique: false }); + } + // v2: no structural change + // v3 + if (!db.objectStoreNames.contains('inbox-cache')) { + db.createObjectStore('inbox-cache', { keyPath: 'userId' }); + } + if (!db.objectStoreNames.contains('task-cache')) { + db.createObjectStore('task-cache', { keyPath: 'taskId' }); + } + if (!db.objectStoreNames.contains('form-cache')) { + db.createObjectStore('form-cache', { keyPath: 'formId' }); + } + // v4 + if (!db.objectStoreNames.contains('directory-cache')) { + db.createObjectStore('directory-cache', { keyPath: 'tableId' }); + } + // v5: attachment-queue для фоновой отправки сообщений с файлами + if (!db.objectStoreNames.contains('attachment-queue')) { + const aqStore = db.createObjectStore('attachment-queue', { keyPath: 'id' }); + aqStore.createIndex('enqueuedAt', 'enqueuedAt', { unique: false }); + } + // v6/v7: synced-* stores для офлайн-кэша форм и задач + const syncedStoreNames = [ + 'synced-forms', + 'synced-tasks', + 'synced-task-field-values', + 'synced-data-tables', + 'synced-data-table-rows', + 'synced-automations', + 'sync-subscriptions', + 'sync-meta', + 'pending-mutations', + 'synced-users', + ]; + for (const name of syncedStoreNames) { + if (!db.objectStoreNames.contains(name)) { + if (name === 'synced-task-field-values') { + const store = db.createObjectStore(name, { keyPath: 'id' }); + store.createIndex('taskId', 'taskId', { unique: false }); + } else if (name === 'synced-data-table-rows') { + const store = db.createObjectStore(name, { keyPath: 'id' }); + store.createIndex('tableId', 'tableId', { unique: false }); + } else { + db.createObjectStore(name, { keyPath: 'id' }); + } + } + } + }; + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); +} + +function getAllQueueItems(db) { + return new Promise((resolve, reject) => { + const tx = db.transaction(IDB_STORE, 'readonly'); + const req = tx.objectStore(IDB_STORE).index('enqueuedAt').getAll(); + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); +} + +function removeQueueItem(db, id) { + return new Promise((resolve, reject) => { + const tx = db.transaction(IDB_STORE, 'readwrite'); + tx.objectStore(IDB_STORE).delete(id); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); +} + +function isSafeToDrop(url, method) { + return method === 'POST' && /\/api\/messages\/\d+\/read$/.test(url); +} + +function fetchWithTimeout(request, timeoutMs) { + return new Promise((resolve, reject) => { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + fetch(request, { signal: controller.signal }) + .then((response) => { + clearTimeout(timer); + resolve(response); + }) + .catch((error) => { + clearTimeout(timer); + reject(error); + }); + }); +} + +const ATTACHMENT_STORE = 'attachment-queue'; + +function getAllAttachmentItems(db) { + return new Promise((resolve, reject) => { + const tx = db.transaction(ATTACHMENT_STORE, 'readonly'); + const req = tx.objectStore(ATTACHMENT_STORE).index('enqueuedAt').getAll(); + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); +} + +function removeAttachmentItem(db, id) { + return new Promise((resolve, reject) => { + const tx = db.transaction(ATTACHMENT_STORE, 'readwrite'); + tx.objectStore(ATTACHMENT_STORE).delete(id); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); +} + +function updateAttachmentItem(db, item) { + return new Promise((resolve, reject) => { + const tx = db.transaction(ATTACHMENT_STORE, 'readwrite'); + tx.objectStore(ATTACHMENT_STORE).put(item); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); +} + +async function uploadFileFromSW(file) { + const formData = new FormData(); + formData.append('file', file.blob, file.name); + const res = await fetch('/api/upload', { + method: 'POST', + body: formData, + credentials: 'include', + }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err.error || `Upload failed: ${res.status}`); + } + return res.json(); +} + +async function sendMessageFromSW(item, attachments) { + const url = item.type === 'messenger' + ? `/api/messenger/conversations/${item.targetId}/messages` + : `/api/tasks/${item.targetId}/messages`; + + const payload = { + message: item.message || '', + attachments, + }; + + if (item.replyToId || item.replyToMessageId) { + if (item.type === 'messenger') { + payload.replyToId = item.replyToId || item.replyToMessageId; + } else { + payload.replyToMessageId = item.replyToMessageId || item.replyToId; + } + } + + if (item.mentionedUserIds && item.mentionedUserIds.length > 0) { + payload.mentionedUserIds = item.mentionedUserIds; + } + if (item.mentionedBotIds && item.mentionedBotIds.length > 0) { + payload.mentionedBotIds = item.mentionedBotIds; + } + + const res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + credentials: 'include', + }); + + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err.error || `Send failed: ${res.status}`); + } + return res.json(); +} + +/** Replay attachment queue directly from the SW. */ +async function replayAttachmentQueueFromSW() { + let db; + try { + db = await openQueueDB(); + } catch { + console.log('[SW] Cannot open IndexedDB for attachment replay'); + return; + } + const items = await getAllAttachmentItems(db); + console.log(`[SW] Replaying ${items.length} attachment operation(s) from SW`); + + for (const item of items) { + // Skip items already in error state (need user action or app open to retry) + if (item.status === 'error') { + console.log(`[SW] Skipping attachment ${item.id} — error state`); + continue; + } + + try { + const attachments = []; + for (const file of item.files || []) { + const data = await uploadFileFromSW(file); + attachments.push({ + url: data.url, + name: data.name, + size: data.size, + mimeType: file.type || undefined, + }); + } + await sendMessageFromSW(item, attachments); + await removeAttachmentItem(db, item.id); + console.log(`[SW] Replayed attachment ${item.id} → success`); + } catch (err) { + console.log(`[SW] Attachment replay error ${item.id}:`, err.message); + // Network errors: stop and retry later + // 401/403: mark error (session may be expired, need app open) + // 4xx validation errors: mark error + // 5xx/429: stop and retry later + if (err.message && (err.message.includes('401') || err.message.includes('403'))) { + item.status = 'error'; + item.error = 'Требуется повторный вход'; + await updateAttachmentItem(db, item); + continue; + } + if (err.message && /\b4\d\d\b/.test(err.message)) { + item.status = 'error'; + item.error = err.message; + await updateAttachmentItem(db, item); + continue; + } + // Transient error — stop replay, next sync will retry + break; + } + } +} + +/** Replay the offline queue directly from the SW (used when no window clients exist). */ +async function replayQueueFromSW() { + let db; + try { + db = await openQueueDB(); + } catch { + console.log('[SW] Cannot open IndexedDB for replay'); + return; + } + const items = await getAllQueueItems(db); + console.log(`[SW] Replaying ${items.length} queued request(s) from SW`); + for (const item of items) { + try { + const headers = { 'Content-Type': 'application/json' }; + const res = await fetch(item.url, { + method: item.method, + headers, + body: item.body ?? undefined, + credentials: 'include', + }); + if (res.ok || (res.status >= 400 && res.status < 500 && res.status !== 429 && isSafeToDrop(item.url, item.method))) { + // Success or safe permanent client error — remove from queue + await removeQueueItem(db, item.id); + console.log(`[SW] Replayed ${item.method} ${item.url} → ${res.status}`); + } else { + // Server error, 429, or non-safe 4xx — stop and let the app handle it + console.log(`[SW] Replay stopped at ${item.method} ${item.url} → ${res.status}`); + break; + } + } catch { + // Network error during SW replay — stop + console.log(`[SW] Replay network error at ${item.method} ${item.url}`); + break; + } + } +} + +// ── Lifecycle ───────────────────────────────────────────────────────────────── + +self.addEventListener('install', (event) => { + console.log('[SW] Installing service worker...'); + event.waitUntil( + caches.open(CACHE_NAME).then((cache) => { + console.log('[SW] Caching static assets'); + return cache.addAll(STATIC_ASSETS); + }) + ); + self.skipWaiting(); +}); + +self.addEventListener('activate', (event) => { + console.log('[SW] Activating service worker...'); + event.waitUntil( + caches.keys().then((cacheNames) => { + return Promise.all( + cacheNames + .filter((name) => name !== CACHE_NAME) + .map((name) => { + console.log('[SW] Deleting old cache:', name); + return caches.delete(name); + }) + ); + }) + ); + self.clients.claim(); +}); + +async function handleShareTarget(request) { + try { + const formData = await request.formData(); + const files = formData.getAll('media'); + const title = formData.get('title') || ''; + const text = formData.get('text') || ''; + const url = formData.get('url') || ''; + + const cache = await caches.open('share-target-files'); + await cache.put('/share-target/data', new Response(JSON.stringify({ + title: String(title), + text: String(text) + (url ? '\n' + url : ''), + fileCount: files.length, + fileNames: files.map(f => (f instanceof File ? f.name : 'file')), + timestamp: Date.now(), + }), { headers: { 'Content-Type': 'application/json' } })); + + for (let i = 0; i < files.length; i++) { + const file = files[i]; + if (file instanceof Blob) { + await cache.put(`/share-target/file/${i}`, new Response(file, { + headers: { 'Content-Type': file.type || 'application/octet-stream' } + })); + } + } + } catch (err) { + console.error('[SW] Share target error:', err); + } + return Response.redirect('/share-target?shared=1', 303); +} + +self.addEventListener('fetch', (event) => { + if (event.request.url.includes('/share-target') && event.request.method === 'POST') { + event.respondWith(handleShareTarget(event.request)); + return; + } + + if (event.request.method !== 'GET') return; + + const url = new URL(event.request.url); + + // Skip API requests + if (url.pathname.startsWith('/api/')) return; + + // Navigation requests (page loads): network-first with a short timeout, + // falling back to the cached shell. This guarantees that users see the + // latest build when they are online, while still keeping the app usable + // during cold PWA starts or flaky connections. + if (event.request.mode === 'navigate') { + event.respondWith( + fetchWithTimeout(event.request, 3000) + .then(async (networkResponse) => { + if (networkResponse.status === 200) { + const clone = networkResponse.clone(); + const cache = await caches.open(CACHE_NAME); + await cache.put('/index.html', clone); + } + return networkResponse; + }) + .catch(async () => { + const cached = await caches.match('/index.html'); + if (cached) return cached; + const rootFallback = await caches.match('/'); + if (rootFallback) return rootFallback; + return new Response('Offline', { status: 503, statusText: 'Service Unavailable' }); + }) + ); + return; + } + + event.respondWith( + fetchWithTimeout(event.request, 5000) + .then((response) => { + if (response.status === 200) { + const responseClone = response.clone(); + caches.open(CACHE_NAME).then((cache) => { + cache.put(event.request, responseClone); + }); + } + return response; + }) + .catch(() => caches.match(event.request)) + ); +}); + +// ── Background Sync ─────────────────────────────────────────────────────────── + +self.addEventListener('sync', (event) => { + if (event.tag === 'offline-queue') { + console.log('[SW] Background sync triggered: offline-queue'); + event.waitUntil( + clients.matchAll({ type: 'window', includeUncontrolled: false }).then(async (clientList) => { + if (clientList.length > 0) { + // App is open — tell it to handle sync (it has access to React Query cache) + for (const client of clientList) { + client.postMessage({ type: 'TRIGGER_SYNC' }); + } + } else { + // App is closed — replay queue directly from SW + await replayQueueFromSW(); + } + }) + ); + } + + if (event.tag === 'attachment-queue') { + console.log('[SW] Background sync triggered: attachment-queue'); + event.waitUntil( + clients.matchAll({ type: 'window', includeUncontrolled: false }).then(async (clientList) => { + if (clientList.length > 0) { + // App is open — let the app handle it so React Query cache is updated + for (const client of clientList) { + client.postMessage({ type: 'TRIGGER_ATTACHMENT_SYNC' }); + } + } else { + // App is closed — replay attachment queue directly from SW + await replayAttachmentQueueFromSW(); + } + }) + ); + } + + if (event.tag === 'offline-sync') { + console.log('[SW] Background sync triggered: offline-sync'); + event.waitUntil( + clients.matchAll({ type: 'window', includeUncontrolled: false }).then((clientList) => { + if (clientList.length > 0) { + // App is open — let the full sync engine run in the window context + for (const client of clientList) { + client.postMessage({ type: 'TRIGGER_SYNC' }); + } + } + // When the app is closed we cannot run the full sync engine (it needs + // React Query and the authenticated fetch layer). The queued mutations + // will be replayed by the offline-queue tag instead. + }) + ); + } +}); + +// ── Push notifications ──────────────────────────────────────────────────────── + +async function hasVisibleClient() { + const clientList = await clients.matchAll({ type: 'window', includeUncontrolled: true }); + return clientList.some((client) => client.visibilityState === 'visible'); +} + +async function sendToForegroundClients(payload) { + const clientList = await clients.matchAll({ type: 'window', includeUncontrolled: true }); + for (const client of clientList) { + client.postMessage({ type: 'FOREGROUND_NOTIFICATION', payload }); + } +} + +self.addEventListener('push', (event) => { + console.log('[SW] Push notification received'); + + let data = { + title: 'Уведомление', + body: 'Новое сообщение', + icon: '/icons/icon-192x192.png', + url: '/', + }; + + if (event.data) { + try { + data = { ...data, ...event.data.json() }; + } catch { + data.body = event.data.text(); + } + } + + // Silent sync trigger: do not show a notification, just request a background sync. + // Support both the new top-level type and the old nested data.type shape. + if (data.type === 'SYNC_NEEDED' || data.data?.type === 'SYNC_NEEDED') { + event.waitUntil( + self.registration.sync + ? self.registration.sync.register('offline-sync').catch(() => {}) + : Promise.resolve() + ); + return; + } + + event.waitUntil( + hasVisibleClient().then((visible) => { + if (visible) { + // Active tab gets an in-app toast instead of a system notification. + return sendToForegroundClients(data); + } + + const options = { + body: data.body || data.message || '', + icon: data.icon || '/icons/icon-192x192.png', + badge: '/icons/icon-192x192.png', + vibrate: [100, 50, 100], + data: { url: data.url || data.click_action || '/', ...data }, + actions: data.actions || [], + // Keep the notification on screen until the user interacts with it + // when the tab is not active. + requireInteraction: true, + // Use a unique tag per task so multiple task notifications stack instead of + // replacing each other, and avoid renotify which can suppress display on some OSs. + tag: data.tag || (data.taskId ? `task-${data.taskId}` : `notif-${Date.now()}`), + }; + + return self.registration.showNotification(data.title, options); + }) + ); +}); + +async function openOrFocusChat(url) { + const clientList = await clients.matchAll({ type: 'window', includeUncontrolled: true }); + // Prefer a client that already has the same path (ignoring query string). + const targetPath = url.split('?')[0]; + for (const client of clientList) { + if (client.url.includes(self.location.origin) && client.url.split('?')[0] === targetPath && 'focus' in client) { + await client.navigate(url); + return client.focus(); + } + } + // Otherwise focus any same-origin client and navigate it. + for (const client of clientList) { + if (client.url.includes(self.location.origin) && 'focus' in client) { + await client.navigate(url); + return client.focus(); + } + } + return clients.openWindow(url); +} + +async function markNotificationRead(data) { + try { + if (data?.type === 'chat' && data?.messageId) { + await fetch(`/api/messages/${data.messageId}/read`, { + method: 'POST', + credentials: 'include', + }); + console.log('[SW] Marked chat message as read:', data.messageId); + } else if (data?.type === 'messenger' && data?.conversationId) { + await fetch(`/api/messenger/conversations/${data.conversationId}/read`, { + method: 'POST', + credentials: 'include', + }); + console.log('[SW] Marked messenger conversation as read:', data.conversationId); + } + } catch (err) { + console.error('[SW] Failed to mark notification as read:', err); + } +} + +self.addEventListener('notificationclick', (event) => { + console.log('[SW] Notification clicked', event.action); + event.notification.close(); + + const data = event.notification.data || {}; + const urlToOpen = data.url || '/'; + + if (event.action === 'read') { + event.waitUntil(markNotificationRead(data)); + return; + } + + // 'reply' or body click: open the chat and focus an existing tab if possible. + event.waitUntil( + Promise.all([ + navigator.clearAppBadge ? navigator.clearAppBadge() : Promise.resolve(), + openOrFocusChat(urlToOpen), + ]) + ); +}); + +// ── Push subscription lifecycle ─────────────────────────────────────────────── + +self.addEventListener('pushsubscriptionchange', (event) => { + console.log('[SW] Push subscription changed'); + event.waitUntil( + clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) => { + if (clientList.length > 0) { + // Ask the app to resubscribe and register the new subscription with the server. + for (const client of clientList) { + client.postMessage({ type: 'PUSH_SUBSCRIPTION_CHANGE', oldEndpoint: event.oldSubscription?.endpoint }); + } + } else { + // No open clients — we cannot fetch a new subscription without user interaction. + // The next app start will detect the missing/expired subscription and re-register. + console.log('[SW] No window clients to handle pushsubscriptionchange'); + } + }) + ); +}); + +self.addEventListener('message', (event) => { + if (event.data?.type === 'SKIP_WAITING') { + self.skipWaiting(); + } +}); diff --git a/client/src/App.tsx b/client/src/App.tsx new file mode 100644 index 0000000..c3a82f7 --- /dev/null +++ b/client/src/App.tsx @@ -0,0 +1,266 @@ +import { Switch, Route } from "wouter"; +import { queryClient } from "./lib/queryClient"; +import { QueryClientProvider } from "@tanstack/react-query"; +import { Toaster } from "@/components/ui/toaster"; +import { ToastAction } from "@/components/ui/toast"; +import { TooltipProvider } from "@/components/ui/tooltip"; +import { useAuthProvider, AuthContext } from "@/hooks/useAuth"; +import { ThemeProvider } from "@/hooks/use-theme"; +import { OfflineSyncProvider } from "@/contexts/OfflineSyncContext"; +import { useEffect, lazy, Suspense, useCallback, type ComponentType, type LazyExoticComponent } from "react"; +import { initializePushNotifications } from "@/lib/push-notifications"; +import { initPWA, initWebPush } from "@/lib/pwa"; +import { useToast } from "@/hooks/use-toast"; +import { usePrefetchOfflineTasks } from "@/hooks/usePrefetchOfflineTasks"; +import { useEvents } from "@/hooks/useEvents"; +import { usePwaFontSize } from "@/hooks/usePwaFontSize"; +import { GlobalErrorBoundary } from "@/components/GlobalErrorBoundary"; +import { Loader2 } from "lucide-react"; + +type LazyModule = { default: ComponentType }; + +function safeLazy(factory: () => Promise>): LazyExoticComponent> { + return lazy(() => + factory().catch((error) => { + console.error('[safeLazy] chunk load failed, reloading page', error); + window.location.reload(); + return { default: (() => null) as unknown as ComponentType }; + }) + ); +} + +const Login = safeLazy(() => import("@/pages/Login")); +const Register = safeLazy(() => import("@/pages/Register")); +const InviteRegister = safeLazy(() => import("@/pages/InviteRegister")); +const UsersPage = safeLazy(() => import("@/pages/Users")); +const UserProfilePage = safeLazy(() => import("@/pages/UserProfilePage")); +const UserFieldsEditor = safeLazy(() => import("@/pages/UserFieldsEditor")); +const Forms = safeLazy(() => import("@/pages/Forms")); +const FormEditor = safeLazy(() => import("@/pages/FormEditor")); +const Tables = safeLazy(() => import("@/pages/Tables")); +const TableEditor = safeLazy(() => import("@/pages/TableEditor")); +const TaskView = safeLazy(() => import("@/pages/TaskView")); +const TaskDetail = safeLazy(() => import("@/pages/TaskDetail")); +const Settings = safeLazy(() => import("@/pages/Settings")); +const ProfileSettings = safeLazy(() => import("@/pages/ProfileSettings")); +const Reports = safeLazy(() => import("@/pages/Reports")); +const Projects = safeLazy(() => import("@/pages/Projects")); +const Bots = safeLazy(() => import("@/pages/Bots")); +const FieldTemplates = safeLazy(() => import("@/pages/GlobalFields")); +const CustomPageView = safeLazy(() => import("@/pages/CustomPageView")); +const PrivacyPolicy = safeLazy(() => import("@/pages/PrivacyPolicy")); +const ForgotPassword = safeLazy(() => import("@/pages/ForgotPassword")); +const ResetPassword = safeLazy(() => import("@/pages/ResetPassword")); +const SuperAdminLogin = safeLazy(() => import("@/pages/SuperAdminLogin")); +const SuperAdminForgotPassword = safeLazy(() => import("@/pages/SuperAdminForgotPassword")); +const SuperAdminResetPassword = safeLazy(() => import("@/pages/SuperAdminResetPassword")); +const SuperAdminDashboard = safeLazy(() => import("@/pages/SuperAdminDashboard")); +const SuperAdminOrgDetail = safeLazy(() => import("@/pages/SuperAdminOrgDetail")); +const Billing = safeLazy(() => import("@/pages/Billing")); +const FinanceDashboard = safeLazy(() => import("@/pages/finance/dashboard")); +const IncomeOverview = safeLazy(() => import("@/pages/finance/income-overview")); +const ExpensesOverview = safeLazy(() => import("@/pages/finance/expenses-overview")); +const IncomeDetail = safeLazy(() => import("@/pages/finance/income")); +const ExpensesDetail = safeLazy(() => import("@/pages/finance/expenses")); +const IncomeCategories = safeLazy(() => import("@/pages/finance/income-categories")); +const ExpenseCategories = safeLazy(() => import("@/pages/finance/expense-categories")); + +const FinanceSettingsPage = safeLazy(() => import("@/pages/finance/settings")); +const DocumentsPage = safeLazy(() => import("@/pages/documents/DocumentsPage").then(m => ({ default: m.DocumentsPage }))); +const TemplateEditorPage = safeLazy(() => import("@/pages/documents/TemplateEditorPage").then(m => ({ default: m.TemplateEditorPage }))); +const GenerationHistoryPage = safeLazy(() => import("@/pages/documents/GenerationHistoryPage").then(m => ({ default: m.GenerationHistoryPage }))); +const AssetsPage = safeLazy(() => import("@/pages/documents/AssetsPage").then(m => ({ default: m.AssetsPage }))); +const PackagesPage = safeLazy(() => import("@/pages/documents/PackagesPage").then(m => ({ default: m.PackagesPage }))); +const SalaryPage = safeLazy(() => import("@/pages/salary")); +const ChatPage = safeLazy(() => import("@/pages/Chat")); +const HomePage = safeLazy(() => import("@/pages/Home")); +const DashboardPage = safeLazy(() => import("@/pages/DashboardPage")); +const CalendarPage = safeLazy(() => import("@/pages/CalendarPage")); +const GanttPage = safeLazy(() => import("@/pages/GanttPage")); +const ShareTarget = safeLazy(() => import("@/pages/ShareTarget")); +const QrHandler = safeLazy(() => import("@/pages/QrHandler")); +const NotFound = safeLazy(() => import("@/pages/not-found")); + +function PageLoader() { + return ( +
    + +
    + ); +} + +function Router() { + return ( + }> + + + + + + + + + + + + + + + + + {/* Organization-specific task routes */} + + + + + + + + + + + + {() => { window.location.href = '/field-templates'; return null; }} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +function PrefetchWrapper({ children }: { children: React.ReactNode }) { + usePrefetchOfflineTasks(); + return <>{children}; +} + +function GlobalEventListener() { + useEvents({ + onNotification: useCallback(async () => { + queryClient.invalidateQueries({ queryKey: ['/api/notifications'] }); + await Promise.all([ + queryClient.refetchQueries({ queryKey: ['home'] }), + queryClient.refetchQueries({ queryKey: ['/api/home/inbox'] }), + queryClient.refetchQueries({ queryKey: ['/api/home/inbox/count'] }), + ]); + }, []), + onTaskUpdated: useCallback(async () => { + await Promise.all([ + queryClient.refetchQueries({ queryKey: ['home'] }), + queryClient.refetchQueries({ queryKey: ['/api/home/inbox'] }), + queryClient.refetchQueries({ queryKey: ['/api/home/inbox/count'] }), + ]); + }, []), + }); + return null; +} + +/** + * Displays in-app toast notifications when a push arrives while the app tab + * is active. The service worker forwards these instead of showing a system + * notification so the user isn't interrupted with an OS-level alert. + */ +function ForegroundPushHandler() { + const { toast } = useToast(); + + useEffect(() => { + if (!('serviceWorker' in navigator)) return; + + const handler = (event: MessageEvent) => { + if (event.data?.type !== 'FOREGROUND_NOTIFICATION') return; + const payload = event.data.payload || {}; + const isChat = payload.data?.type === 'chat' || payload.data?.type === 'messenger'; + const url = payload.url || payload.data?.url || '/'; + + toast({ + title: payload.title || 'Уведомление', + description: payload.body || payload.message || '', + duration: 6000, + action: isChat ? ( + { window.location.href = url; }}> + Ответить + + ) : undefined, + }); + }; + + navigator.serviceWorker.addEventListener('message', handler); + return () => navigator.serviceWorker.removeEventListener('message', handler); + }, [toast]); + + return null; +} + +function App() { + const auth = useAuthProvider(); + usePwaFontSize(auth.user?.id); + + useEffect(() => { + initPWA(); + }, []); + + useEffect(() => { + if (auth.isAuthenticated) { + initializePushNotifications().catch(console.error); + initWebPush().catch(console.error); + } + }, [auth.isAuthenticated]); + + return ( + + + + + + + + + + + + + + + + + + + ); +} + +export default App; diff --git a/client/src/components/AddBookmarkButton.tsx b/client/src/components/AddBookmarkButton.tsx new file mode 100644 index 0000000..931ea60 --- /dev/null +++ b/client/src/components/AddBookmarkButton.tsx @@ -0,0 +1,55 @@ +import { Button } from '@/components/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { Plus, Bookmark, FolderPlus } from 'lucide-react'; +import { CreateFolderModal } from './CreateFolderModal'; +import { CreateBookmarkModal } from './CreateBookmarkModal'; + +interface AddBookmarkButtonProps { + className?: string; +} + +export function AddBookmarkButton({ className }: AddBookmarkButtonProps) { + return ( + + + + + + {/* Добавить закладку */} + e.preventDefault()} data-testid="item-add-bookmark"> + + Добавить закладку + + } + /> + + + + {/* Добавить папку */} + e.preventDefault()} data-testid="item-add-folder"> + + Добавить папку + + } + /> + + + ); +} \ No newline at end of file diff --git a/client/src/components/AddBookmarkFAB.tsx b/client/src/components/AddBookmarkFAB.tsx new file mode 100644 index 0000000..411ddf2 --- /dev/null +++ b/client/src/components/AddBookmarkFAB.tsx @@ -0,0 +1,37 @@ +import { Plus } from 'lucide-react'; +import { useLocation } from 'wouter'; +import { CreateBookmarkModal } from '@/components/CreateBookmarkModal'; +import { Button } from '@/components/ui/button'; + +const BOOKMARKABLE_PATHS = [ + '/forms/', + '/tasks/', + '/dashboard', + '/kanban/', + '/reports/', + '/home', +]; + +export function AddBookmarkFAB() { + const [location] = useLocation(); + + const isBookmarkable = BOOKMARKABLE_PATHS.some((path) => location === path || location.startsWith(path)); + if (!isBookmarkable) return null; + + const currentUrl = typeof window !== 'undefined' ? window.location.pathname + window.location.search : location; + + return ( + + + + } + /> + ); +} diff --git a/client/src/components/BookmarkFolder.tsx b/client/src/components/BookmarkFolder.tsx new file mode 100644 index 0000000..6237ecb --- /dev/null +++ b/client/src/components/BookmarkFolder.tsx @@ -0,0 +1,208 @@ +import { useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { + Folder, + FolderOpen, + ChevronDown, + ChevronRight, + Plus, + Edit, + Trash2, + MoreHorizontal +} from 'lucide-react'; +import type { BookmarkFolder as BookmarkFolderType, Bookmark } from '@shared/schema'; +import { useUpdateBookmarkFolder, useDeleteBookmarkFolder, useBookmarksByFolder } from '@/hooks/useBookmarks'; +import { CreateFolderModal } from './CreateFolderModal'; +import { CreateBookmarkModal } from './CreateBookmarkModal'; +import { EditBookmarkModal } from './EditBookmarkModal'; +import { BookmarkItem } from './BookmarkItem'; + +interface BookmarkFolderProps { + folder: BookmarkFolderType; + level?: number; + children?: React.ReactNode; + onToggleExpanded?: (folderId: number, isExpanded: boolean) => void; +} + +export function BookmarkFolder({ + folder, + level = 0, + children, + onToggleExpanded +}: BookmarkFolderProps) { + const [showCreateModal, setShowCreateModal] = useState(false); + const [editingBookmark, setEditingBookmark] = useState(null); + const updateFolder = useUpdateBookmarkFolder(); + const deleteFolder = useDeleteBookmarkFolder(); + const { data: bookmarksData } = useBookmarksByFolder(folder.id); + + const isExpanded = folder.isExpanded ?? true; + + const handleToggleExpanded = () => { + const newExpanded = !isExpanded; + updateFolder.mutate({ + id: folder.id, + isExpanded: newExpanded + }); + onToggleExpanded?.(folder.id, newExpanded); + }; + + const handleDelete = () => { + if (window.confirm(`Удалить папку "${folder.name}" и все её содержимое?`)) { + deleteFolder.mutate(folder.id); + } + }; + + const handleEditBookmark = (bookmark: Bookmark) => { + setEditingBookmark(bookmark); + }; + + return ( +
    +
    + {/* Expand/Collapse button */} + + + {/* Folder icon and name */} +
    + {isExpanded ? ( + + ) : ( + + )} + + {folder.name} + +
    + + {/* Actions dropdown */} + + + + + + e.preventDefault()}> + + Создать подпапку + + } + /> + + + Переименовать + + + + + Удалить папку + + + +
    + + {/* Children content */} + {isExpanded && ( +
    + {/* Показываем закладки в папке */} + {bookmarksData?.bookmarks && bookmarksData.bookmarks.length > 0 && ( +
    + {bookmarksData.bookmarks + .sort((a, b) => a.position - b.position) + .map(bookmark => ( + + ))} + + {/* Кнопка добавления закладки в папку */} + + + + } + /> +
    + )} + + {/* Если закладок нет, показываем кнопку добавления */} + {(!bookmarksData?.bookmarks || bookmarksData.bookmarks.length === 0) && ( +
    + + + Добавить закладку + + } + /> +
    + )} + + {/* Дочерние папки */} + {children} +
    + )} + + {/* Модал редактирования закладки */} + {editingBookmark && ( + !open && setEditingBookmark(null)} + /> + )} +
    + ); +} \ No newline at end of file diff --git a/client/src/components/BookmarkFolderDropdown.tsx b/client/src/components/BookmarkFolderDropdown.tsx new file mode 100644 index 0000000..e354ad7 --- /dev/null +++ b/client/src/components/BookmarkFolderDropdown.tsx @@ -0,0 +1,538 @@ +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from '@/components/ui/dialog'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@/components/ui/tooltip'; +import { + Folder, + FolderOpen, + Plus, + Edit, + Trash2, + ChevronRight, + MoreHorizontal, + Bookmark +} from 'lucide-react'; +import { useLocation } from 'wouter'; +import type { BookmarkFolder as BookmarkFolderType, Bookmark as BookmarkTypeSchema } from '@shared/schema'; +import { useUpdateBookmarkFolder, useDeleteBookmarkFolder, useBookmarksByFolder } from '@/hooks/useBookmarks'; +import { useState } from 'react'; +import { CreateFolderModal } from './CreateFolderModal'; +import { CreateBookmarkModal } from './CreateBookmarkModal'; +import { EditBookmarkModal } from './EditBookmarkModal'; +import { BookmarkItem } from './BookmarkItem'; + +interface BookmarkFolderDropdownProps { + folder: BookmarkFolderType; + allFolders: BookmarkFolderType[]; + collapsed?: boolean; +} + +interface BookmarkSubfolderProps { + folder: BookmarkFolderType; + allFolders: BookmarkFolderType[]; +} + +interface CollapsedAccordionSubfolderProps { + folder: BookmarkFolderType; + allFolders: BookmarkFolderType[]; + depth?: number; + onNavigate: (bookmark: BookmarkTypeSchema) => void; +} + +function getAbbr(name: string): string { + const words = name.trim().split(/\s+/); + if (words.length >= 2) return (words[0][0] + words[1][0]).toUpperCase(); + return name.slice(0, 2).toUpperCase(); +} + +// Рекурсивный аккордеон-компонент для вложенных папок в свёрнутом режиме +function CollapsedAccordionSubfolder({ folder, allFolders, depth = 1, onNavigate }: CollapsedAccordionSubfolderProps) { + const [expanded, setExpanded] = useState(false); + const { data: bookmarksData } = useBookmarksByFolder(folder.id); + + const bookmarks = [...(bookmarksData?.bookmarks || [])].sort((a, b) => a.position - b.position); + const subfolders = allFolders.filter(f => f.parentId === folder.id).sort((a, b) => a.position - b.position); + + const indentStyle = { paddingLeft: `${depth * 8}px` }; + + return ( +
    + + +
    setExpanded(prev => !prev)} + data-testid={`collapsed-subfolder-${folder.id}`} + > + + {getAbbr(folder.name)} + +
    +
    + + {folder.name} + +
    + + {expanded && ( +
    + {bookmarks.map(bookmark => ( + + +
    onNavigate(bookmark)} + data-testid={`collapsed-bookmark-child-${bookmark.id}`} + > + + {getAbbr(bookmark.name)} + +
    +
    + + {bookmark.name} + +
    + ))} + + {subfolders.map(subfolder => ( + + ))} +
    + )} +
    + ); +} + +// Упрощённая подпапка для свёрнутого режима — только навигация, без действий +interface CollapsedSubfolderMenuProps { + folder: BookmarkFolderType; + allFolders: BookmarkFolderType[]; + onNavigate: (bookmark: BookmarkTypeSchema) => void; +} + +function CollapsedSubfolderMenu({ folder, allFolders, onNavigate }: CollapsedSubfolderMenuProps) { + const { data: bookmarksData } = useBookmarksByFolder(folder.id); + const bookmarks = [...(bookmarksData?.bookmarks || [])].sort((a, b) => a.position - b.position); + const subfolders = allFolders.filter(f => f.parentId === folder.id).sort((a, b) => a.position - b.position); + + return ( + + + {folder.name} + + + {bookmarks.map(bookmark => ( + onNavigate(bookmark)}> + {bookmark.name} + + ))} + {subfolders.map(subfolder => ( + + ))} + {bookmarks.length === 0 && subfolders.length === 0 && ( + + Папка пуста + + )} + + + ); +} + +// Компонент для отображения подпапки как submenu (раскрывается вправо) — только для развёрнутого режима +function BookmarkSubfolder({ folder, allFolders }: BookmarkSubfolderProps) { + const [editingBookmark, setEditingBookmark] = useState(null); + const { data: bookmarksData } = useBookmarksByFolder(folder.id); + const deleteFolder = useDeleteBookmarkFolder(); + + const bookmarks = bookmarksData?.bookmarks || []; + const subfolders = allFolders.filter(f => f.parentId === folder.id); + + const handleDelete = () => { + if (window.confirm(`Удалить папку "${folder.name}" и все её содержимое?`)) { + deleteFolder.mutate(folder.id); + } + }; + + const handleEditBookmark = (bookmark: BookmarkTypeSchema) => { + setEditingBookmark(bookmark); + }; + + return ( + + + + {folder.name} + + + + {bookmarks.map(bookmark => ( + +
    + +
    +
    + ))} + + {subfolders.map(subfolder => ( + + ))} + + {(bookmarks.length > 0 || subfolders.length > 0) && } + + + + Действия + + + e.preventDefault()} data-testid={`item-add-bookmark-subfolder-${folder.id}`}> + + Добавить закладку + + } + /> + e.preventDefault()} data-testid={`item-add-subfolder-${folder.id}`}> + + Создать подпапку + + } + /> + + + Переименовать + + + + + Удалить папку + + + + + {editingBookmark && ( + !open && setEditingBookmark(null)} + /> + )} +
    +
    + ); +} + +// Основной компонент для папки в bookmarks bar +export function BookmarkFolderDropdown({ folder, allFolders, collapsed = false }: BookmarkFolderDropdownProps) { + const [editingBookmark, setEditingBookmark] = useState(null); + const [renamingFolder, setRenamingFolder] = useState(false); + const [newFolderName, setNewFolderName] = useState(folder.name); + const [expandedCollapsed, setExpandedCollapsed] = useState(false); + const [, setLocation] = useLocation(); + const { data: bookmarksData } = useBookmarksByFolder(folder.id); + const deleteFolder = useDeleteBookmarkFolder(); + const updateFolder = useUpdateBookmarkFolder(); + + const bookmarks = [...(bookmarksData?.bookmarks || [])].sort((a, b) => a.position - b.position); + const subfolders = allFolders.filter(f => f.parentId === folder.id).sort((a, b) => a.position - b.position); + + const handleDelete = () => { + if (window.confirm(`Удалить папку "${folder.name}" и все её содержимое?`)) { + deleteFolder.mutate(folder.id); + } + }; + + const handleEditBookmark = (bookmark: BookmarkTypeSchema) => { + setEditingBookmark(bookmark); + }; + + const handleRenameFolder = () => { + setNewFolderName(folder.name); + setRenamingFolder(true); + }; + + const handleSaveRename = () => { + if (newFolderName.trim() && newFolderName !== folder.name) { + updateFolder.mutate({ id: folder.id, name: newFolderName.trim() }); + } + setRenamingFolder(false); + }; + + const getBookmarkUrl = (bookmark: BookmarkTypeSchema) => { + switch (bookmark.type) { + case 'form': + return `/forms/${bookmark.targetId}/tasks`; + case 'task': + return bookmark.formId ? `/forms/${bookmark.formId}/tasks/${bookmark.targetId}` : `/tasks/${bookmark.targetId}`; + case 'kanban': + return `/kanban/${bookmark.targetId}`; + case 'report': + return `/reports/${bookmark.targetId}`; + case 'dashboard': + return '/'; + case 'custom_url': + return bookmark.url || '#'; + default: + return '/'; + } + }; + + const handleNavigate = (bookmark: BookmarkTypeSchema) => { + const url = getBookmarkUrl(bookmark); + if (!url || url === '#') return; + if (url.startsWith('http://') || url.startsWith('https://')) { + window.open(url, '_blank', 'noopener,noreferrer'); + } else { + setLocation(url); + } + }; + + // В свёрнутом режиме — DropdownMenu, раскрывается вправо + if (collapsed) { + return ( + <> + + + + +
    + + {getAbbr(folder.name)} + +
    +
    +
    + + {folder.name} + +
    + + + {bookmarks.map(bookmark => ( + handleNavigate(bookmark)} + data-testid={`collapsed-bookmark-${bookmark.id}`} + > + {bookmark.name} + + ))} + + {subfolders.map(subfolder => ( + + ))} + + {bookmarks.length === 0 && subfolders.length === 0 && ( + + Папка пуста + + )} + +
    + + {editingBookmark && ( + !open && setEditingBookmark(null)} + /> + )} + + ); + } + + // В развёрнутом режиме используем DropdownMenu как раньше + const folderButton = ( + + ); + + return ( + <> + + + {folderButton} + + + {bookmarks.map(bookmark => ( + e.preventDefault()} + > +
    + +
    +
    + ))} + + {subfolders.map(subfolder => ( + + ))} + + {bookmarks.length === 0 && subfolders.length === 0 && ( + + Папка пуста + + )} + + {(bookmarks.length > 0 || subfolders.length > 0) && } + + + + Действия + + + e.preventDefault()} data-testid={`item-add-bookmark-folder-${folder.id}`}> + + Добавить закладку + + } + /> + e.preventDefault()} data-testid={`item-add-subfolder-${folder.id}`}> + + Создать подпапку + + } + /> + + + Переименовать + + + + + Удалить папку + + + +
    +
    + + {editingBookmark && ( + !open && setEditingBookmark(null)} + /> + )} + + + + + Переименовать папку + +
    + setNewFolderName(e.target.value)} + placeholder="Название папки" + autoFocus + onKeyDown={(e) => { + if (e.key === 'Enter') { + handleSaveRename(); + } + }} + data-testid="input-rename-folder" + /> +
    + + + + +
    +
    + + ); +} diff --git a/client/src/components/BookmarkItem.tsx b/client/src/components/BookmarkItem.tsx new file mode 100644 index 0000000..a333158 --- /dev/null +++ b/client/src/components/BookmarkItem.tsx @@ -0,0 +1,198 @@ +import { Button } from '@/components/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { + FileText, + CheckSquare, + BarChart3, + Globe, + Calendar, + Home, + Link, + LayoutGrid, + MoreHorizontal, + Edit, + Trash, + Copy, +} from 'lucide-react'; +import type { Bookmark } from '@shared/schema'; +import { useDeleteBookmark } from '@/hooks/useBookmarks'; +import { useLocation } from 'wouter'; + +const typeIconMap = { + form: FileText, + task: CheckSquare, + kanban: Calendar, + report: BarChart3, + dashboard: Home, + custom_url: Globe, +} as const; + +function getBookmarkUrl(bookmark: Bookmark): string { + switch (bookmark.type) { + case 'form': + return `/forms/${bookmark.targetId}/tasks`; + case 'task': + return bookmark.formId + ? `/forms/${bookmark.formId}/tasks/${bookmark.targetId}` + : `/tasks/${bookmark.targetId}`; + case 'kanban': + return `/kanban/${bookmark.targetId}`; + case 'report': + return `/reports/${bookmark.targetId}`; + case 'dashboard': + return '/'; + case 'custom_url': + return bookmark.url || '#'; + default: + return '/'; + } +} + +const APP_ROUTE_PREFIXES = ['/forms/', '/tasks/', '/kanban/', '/reports/']; +// Replit uses multiple preview/deploy subdomains for the same app (e.g. +// ..replit.dev, .id.repl.co). A bookmark saved in one context +// may carry a hostname that differs from the current preview URL. Treat URLs on +// these platforms as internal when their path matches a known app route prefix. +const REPLIT_APP_HOSTNAME_PATTERN = /\.(replit\.dev|repl\.co)$/i; + +function isInternalUrl(url: string): boolean { + if (!url || url === '#') return false; + if (url.startsWith('/')) return true; + try { + const parsed = new URL(url); + // Primary check: same hostname as the current page. + if (parsed.hostname === window.location.hostname) return true; + // Replit-context fallback: different Replit preview/deploy subdomain but + // same app. Only trusted if the hostname is a known Replit domain. + if (REPLIT_APP_HOSTNAME_PATTERN.test(parsed.hostname)) { + if (APP_ROUTE_PREFIXES.some(p => parsed.pathname.startsWith(p))) return true; + } + } catch { + return false; + } + return false; +} + +function toInternalPath(url: string): string { + if (url.startsWith('/')) return url; + try { + const parsed = new URL(url); + return parsed.pathname + parsed.search + parsed.hash; + } catch { + return url; + } +} + +function getCustomUrlIcon(url: string) { + if (!isInternalUrl(url)) return Globe; + const path = toInternalPath(url); + if (path.startsWith('/forms/')) return FileText; + if (path.startsWith('/tasks/')) return CheckSquare; + return LayoutGrid; +} + +interface BookmarkItemProps { + bookmark: Bookmark; + onEdit?: (bookmark: Bookmark) => void; +} + +export function BookmarkItem({ bookmark, onEdit }: BookmarkItemProps) { + const deleteBookmark = useDeleteBookmark(); + const [, navigate] = useLocation(); + const isDeleting = deleteBookmark.isPending; + + const url = getBookmarkUrl(bookmark); + const IconComponent = + bookmark.type === 'custom_url' + ? getCustomUrlIcon(url) + : (typeIconMap[bookmark.type as keyof typeof typeIconMap] || Link); + const internal = isInternalUrl(url); + + const handleBookmarkClick = () => { + if (internal) { + navigate(toInternalPath(url)); + } else { + window.open(url, '_blank', 'noopener,noreferrer'); + } + }; + + const handleDelete = () => { + if (isDeleting) return; + if (window.confirm(`Удалить закладку "${bookmark.name}"?`)) { + deleteBookmark.mutate(bookmark.id, { + onError: (error) => { + console.error('Error deleting bookmark:', error); + } + }); + } + }; + + const handleCopyUrl = () => { + const copyUrl = internal + ? `${window.location.origin}${toInternalPath(url)}` + : url; + if (copyUrl) { + navigator.clipboard.writeText(copyUrl); + } + }; + + if (isDeleting) return null; + + return ( +
    + + + + + + + + onEdit?.(bookmark)} data-testid={`bookmark-edit-${bookmark.id}`}> + + Редактировать + + + + + Копировать ссылку + + + + + + + Удалить + + + +
    + ); +} diff --git a/client/src/components/BookmarksBar.tsx b/client/src/components/BookmarksBar.tsx new file mode 100644 index 0000000..811f44f --- /dev/null +++ b/client/src/components/BookmarksBar.tsx @@ -0,0 +1,222 @@ +import { Link } from 'wouter'; +import { Button } from '@/components/ui/button'; +import { UserAvatar } from '@/components/UserAvatar'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu'; +import { + Users, + Settings, + BarChart3, + Home, + Bell, + ChevronDown, + LogOut, + FileText, + Table2, + Moon, + Sun +} from 'lucide-react'; +import { useAuth } from '@/hooks/useAuth'; +import { useTheme } from '@/hooks/use-theme'; +import { Switch } from '@/components/ui/switch'; +import { useBookmarkFolders, useBookmarksByFolder } from '@/hooks/useBookmarks'; +import { BookmarkFolderDropdown } from './BookmarkFolderDropdown'; +import { BookmarkItem } from './BookmarkItem'; +import { EditBookmarkModal } from './EditBookmarkModal'; +import { AddBookmarkButton } from './AddBookmarkButton'; +import { NotificationsDropdown } from './NotificationsDropdown'; +import type { BookmarkFolder as BookmarkFolderType, Bookmark } from '@shared/schema'; +import { useState } from 'react'; + +// Компонент для отображения папок закладок +function BookmarkFolders() { + const [editingBookmark, setEditingBookmark] = useState(null); + const { data: foldersData, isLoading: foldersLoading } = useBookmarkFolders(); + const { data: rootBookmarksData, isLoading: bookmarksLoading } = useBookmarksByFolder(null); + + if (foldersLoading || bookmarksLoading) { + return ( +
    +
    Загрузка...
    +
    + ); + } + + const folders = foldersData?.folders || []; + const rootFolders = folders.filter(f => f.parentId === null); + const rootBookmarks = rootBookmarksData?.bookmarks || []; + + const handleEditBookmark = (bookmark: Bookmark) => { + setEditingBookmark(bookmark); + }; + + return ( +
    + + {/* Отображение корневых закладок (без папок) */} + {rootBookmarks.length > 0 && ( +
    + {rootBookmarks + .sort((a, b) => a.position - b.position) + .map(bookmark => ( + + ))} +
    + )} + + {/* Отображение корневых папок как dropdown меню */} +
    + {rootFolders + .sort((a, b) => a.position - b.position) + .map(folder => ( + + ))} +
    + + {/* Универсальная кнопка добавления */} + + + {/* Модал редактирования закладки */} + {editingBookmark && ( + !open && setEditingBookmark(null)} + /> + )} +
    + ); +} + +const BookmarksBar = () => { + const { user, logout } = useAuth(); + const { theme, toggleTheme } = useTheme(); + + if (!user) { + return null; + } + + + + return ( +
    + {/* Left side - Home icon */} +
    + + + + + {/* Bookmarks area */} + +
    + + {/* Right side - Notifications and User menu */} +
    + + + {/* User Menu */} + + + + + + + + + Пользователи + + + + + + Формы + + + + + + Справочники + + + + + + Настройки приложения + + + + + + Настройки профиля + + + { + e.preventDefault(); + }} + className="flex items-center justify-between cursor-pointer" + data-testid="item-theme-toggle" + > +
    + {theme === 'dark' ? ( + + ) : ( + + )} + Тёмная тема +
    + { + if (checked !== (theme === 'dark')) { + toggleTheme(); + } + }} + onClick={(e) => e.stopPropagation()} + className="ml-2" + data-testid="switch-theme" + /> +
    + + + + Выйти + +
    +
    +
    +
    + ); +}; + +export default BookmarksBar; \ No newline at end of file diff --git a/client/src/components/CompanyFieldInput.tsx b/client/src/components/CompanyFieldInput.tsx new file mode 100644 index 0000000..aa4fd36 --- /dev/null +++ b/client/src/components/CompanyFieldInput.tsx @@ -0,0 +1,167 @@ +import { useState, useRef, useEffect, useCallback } from "react"; +import { Input } from "@/components/ui/input"; +import { Popover, PopoverAnchor, PopoverContent } from "@/components/ui/popover"; +import { apiRequest } from "@/lib/queryClient"; +import { Loader2 } from "lucide-react"; + +export interface DadataCompany { + value: string; + data: { + inn: string; + kpp: string; + ogrn: string; + name: { + full_with_opf: string; + short_with_opf: string; + }; + management?: { + name: string; + post: string; + }; + address?: { + value: string; + }; + state?: { + status: string; + }; + }; +} + +interface CompanyFieldInputProps { + value: string; + onChange: (value: string) => void; + onSelect: (company: DadataCompany) => void; + onClear?: () => void; + placeholder?: string; + disabled?: boolean; +} + +export default function CompanyFieldInput({ + value, + onChange, + onSelect, + onClear, + placeholder = "Начните вводить название компании", + disabled = false, +}: CompanyFieldInputProps) { + const [inputValue, setInputValue] = useState(value); + const [suggestions, setSuggestions] = useState([]); + const [isOpen, setIsOpen] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const inputRef = useRef(null); + const debounceRef = useRef(null); + // Tracks the last full company.value we passed to onChange after selection, + // so the sync effect below does not overwrite the short display name. + const lastSelectedFullValueRef = useRef(null); + + useEffect(() => { + // Skip sync when value equals the full name we just set via onChange after + // a company selection — the display should keep showing the short name. + if (lastSelectedFullValueRef.current !== null && value === lastSelectedFullValueRef.current) { + return; + } + lastSelectedFullValueRef.current = null; + setInputValue(value); + }, [value]); + + const searchCompanies = useCallback(async (query: string) => { + if (!query || query.length < 2) { + setSuggestions([]); + setIsOpen(false); + return; + } + setIsLoading(true); + try { + const response = await apiRequest("POST", "/api/dadata/suggest/party", { query }); + const data = await response.json(); + const results: DadataCompany[] = data.suggestions || []; + setSuggestions(results); + setIsOpen(results.length > 0); + } catch (error) { + console.error("Error fetching company suggestions:", error); + setSuggestions([]); + setIsOpen(false); + } finally { + setIsLoading(false); + } + }, []); + + const handleInputChange = (e: React.ChangeEvent) => { + const newValue = e.target.value; + lastSelectedFullValueRef.current = null; + setInputValue(newValue); + onChange(newValue); + onClear?.(); + if (debounceRef.current) clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(() => searchCompanies(newValue), 300); + }; + + const handleSelectCompany = (company: DadataCompany) => { + const displayName = company.data.name.short_with_opf || company.value; + // Remember the full value we're about to pass to onChange so the sync + // effect does not overwrite our short display name. + lastSelectedFullValueRef.current = company.value; + setInputValue(displayName); + onChange(company.value); + onSelect(company); + setIsOpen(false); + setSuggestions([]); + }; + + return ( + + +
    + + {isLoading && ( +
    + +
    + )} +
    +
    + e.preventDefault()} + onInteractOutside={(e) => { + if (inputRef.current && inputRef.current.contains(e.target as Node)) { + e.preventDefault(); + } + }} + data-testid="dropdown-company-suggestions" + > +
      + {suggestions.map((company, index) => ( +
    • { + e.preventDefault(); + handleSelectCompany(company); + }} + data-testid={`suggestion-company-${index}`} + > +
      {company.value}
      +
      + ИНН: {company.data.inn} + {company.data.address?.value && ( + {company.data.address.value} + )} +
      +
    • + ))} +
    +
    +
    + ); +} diff --git a/client/src/components/ContactField.tsx b/client/src/components/ContactField.tsx new file mode 100644 index 0000000..fda68a3 --- /dev/null +++ b/client/src/components/ContactField.tsx @@ -0,0 +1,199 @@ +import { useState, useEffect } from "react"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { Plus, X } from "lucide-react"; + +export interface ContactValue { + name?: string; + position?: string; + phones?: string[]; + emails?: string[]; +} + +interface ContactFieldProps { + value: ContactValue | string | null; + onChange: (value: ContactValue) => void; + disabled?: boolean; +} + +function safeParseContact(value: ContactValue | string | null): ContactValue { + if (!value) return {}; + if (typeof value === "string") { + try { + return JSON.parse(value) as ContactValue; + } catch { + return {}; + } + } + return value as ContactValue; +} + +export default function ContactField({ value, onChange, disabled = false }: ContactFieldProps) { + const parsed = safeParseContact(value); + const [name, setName] = useState(parsed.name || ""); + const [position, setPosition] = useState(parsed.position || ""); + const [phones, setPhones] = useState(parsed.phones?.length ? parsed.phones : [""]); + const [emails, setEmails] = useState(parsed.emails?.length ? parsed.emails : [""]); + + useEffect(() => { + const p = safeParseContact(value); + setName(p.name || ""); + setPosition(p.position || ""); + setPhones(p.phones?.length ? p.phones : [""]); + setEmails(p.emails?.length ? p.emails : [""]); + }, [value]); + + const buildValue = (partial: Partial<{ name: string; position: string; phones: string[]; emails: string[] }>): ContactValue => { + const n = partial.name !== undefined ? partial.name : name; + const pos = partial.position !== undefined ? partial.position : position; + const ph = partial.phones !== undefined ? partial.phones : phones; + const em = partial.emails !== undefined ? partial.emails : emails; + const result: ContactValue = {}; + if (n.trim()) result.name = n.trim(); + if (pos.trim()) result.position = pos.trim(); + const cleanPhones = ph.map((s) => s.trim()).filter(Boolean); + if (cleanPhones.length) result.phones = cleanPhones; + const cleanEmails = em.map((s) => s.trim()).filter(Boolean); + if (cleanEmails.length) result.emails = cleanEmails; + return result; + }; + + const handleNameChange = (v: string) => { + setName(v); + onChange(buildValue({ name: v })); + }; + const handlePositionChange = (v: string) => { + setPosition(v); + onChange(buildValue({ position: v })); + }; + + const updatePhones = (next: string[]) => { + setPhones(next); + onChange(buildValue({ phones: next })); + }; + const updateEmails = (next: string[]) => { + setEmails(next); + onChange(buildValue({ emails: next })); + }; + + const addPhone = () => updatePhones([...phones, ""]); + const removePhone = (idx: number) => updatePhones(phones.filter((_, i) => i !== idx)); + const changePhone = (idx: number, v: string) => { + const next = [...phones]; + next[idx] = v; + updatePhones(next); + }; + + const addEmail = () => updateEmails([...emails, ""]); + const removeEmail = (idx: number) => updateEmails(emails.filter((_, i) => i !== idx)); + const changeEmail = (idx: number, v: string) => { + const next = [...emails]; + next[idx] = v; + updateEmails(next); + }; + + return ( +
    +
    + + handleNameChange(e.target.value)} + disabled={disabled} + placeholder="Иванов И.И." + className="h-8" + /> +
    +
    + + handlePositionChange(e.target.value)} + disabled={disabled} + placeholder="Директор" + className="h-8" + /> +
    + +
    + +
    + {phones.map((phone, idx) => ( +
    + changePhone(idx, e.target.value)} + disabled={disabled} + placeholder="+7 (999) 123-45-67" + className="h-8 flex-1" + /> + {phones.length > 1 && ( + + )} +
    + ))} + +
    +
    + +
    + +
    + {emails.map((email, idx) => ( +
    + changeEmail(idx, e.target.value)} + disabled={disabled} + placeholder="ivan@example.com" + className="h-8 flex-1" + /> + {emails.length > 1 && ( + + )} +
    + ))} + +
    +
    +
    + ); +} diff --git a/client/src/components/ContractNumberDisplay.tsx b/client/src/components/ContractNumberDisplay.tsx new file mode 100644 index 0000000..ebcb31f --- /dev/null +++ b/client/src/components/ContractNumberDisplay.tsx @@ -0,0 +1,57 @@ +import { useMemo } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { FileSignature } from "lucide-react"; + +interface ContractNumberDisplayProps { + value: unknown; + legalEntityFormId?: number; +} + +function parseValue(val: unknown): { mode?: string; value?: string; legalEntityTaskId?: number } | null { + if (!val) return null; + if (typeof val === "string") { + try { + return JSON.parse(val) as { mode?: string; value?: string; legalEntityTaskId?: number }; + } catch { + return { mode: "customer", value: val }; + } + } + if (typeof val === "object" && val !== null) { + return val as { mode?: string; value?: string; legalEntityTaskId?: number }; + } + return null; +} + +export default function ContractNumberDisplay({ + value, + legalEntityFormId, +}: ContractNumberDisplayProps) { + const parsed = parseValue(value); + const contractNumber = parsed?.value || ""; + const legalEntityTaskId = parsed?.legalEntityTaskId; + + const { data: tasksData } = useQuery<{ tasks: Array<{ id: number; title: string }> }>({ + queryKey: ["/api/forms", legalEntityFormId, "tasks"], + enabled: !!legalEntityFormId && !!legalEntityTaskId, + }); + + const entityTitle = useMemo(() => { + if (!legalEntityTaskId || !tasksData?.tasks) return ""; + const task = tasksData.tasks.find((t) => t.id === legalEntityTaskId); + return task?.title || ""; + }, [legalEntityTaskId, tasksData]); + + if (!contractNumber) { + return —; + } + + return ( + + + {contractNumber} + {entityTitle && ( + — {entityTitle} + )} + + ); +} diff --git a/client/src/components/ContractNumberField.tsx b/client/src/components/ContractNumberField.tsx new file mode 100644 index 0000000..6ce476d --- /dev/null +++ b/client/src/components/ContractNumberField.tsx @@ -0,0 +1,194 @@ +import { useState, useEffect, useCallback } from "react"; +import { Input } from "@/components/ui/input"; +import { Switch } from "@/components/ui/switch"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { apiRequest } from "@/lib/queryClient"; +import { useQuery } from "@tanstack/react-query"; +import { FileSignature, Loader2 } from "lucide-react"; + +export interface ContractNumberValue { + mode: 'mine' | 'customer'; + value: string; + legalEntityTaskId?: number; +} + +export interface ContractNumberConfig { + legalEntityFormId: number; + prefixFieldCode: string; + startNumber: number; + sequencePadding: number; +} + +interface ContractNumberFieldProps { + value?: ContractNumberValue | string; + onChange: (value: ContractNumberValue) => void; + disabled?: boolean; + config?: ContractNumberConfig; + formId?: number; + fieldId?: number; +} + +function parseValue(val: ContractNumberValue | string | undefined): ContractNumberValue { + if (!val) return { mode: 'mine', value: '' }; + if (typeof val === 'string') { + try { + return JSON.parse(val) as ContractNumberValue; + } catch { + return { mode: 'customer', value: val }; + } + } + return val; +} + +export default function ContractNumberField({ + value, + onChange, + disabled = false, + config, + formId, + fieldId, +}: ContractNumberFieldProps) { + const parsed = parseValue(value); + const [mode, setMode] = useState<'mine' | 'customer'>(parsed.mode); + const [customerValue, setCustomerValue] = useState(parsed.mode === 'customer' ? parsed.value : ''); + const [preview, setPreview] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + + const legalEntityFormId = config?.legalEntityFormId; + const legalEntityTaskId = parsed.legalEntityTaskId; + + // Fetch tasks from legal entity form (always, regardless of mode) + const { data: tasksData } = useQuery<{ tasks: Array<{ id: number; title: string }> }>({ + queryKey: ['/api/forms', legalEntityFormId, 'tasks'], + enabled: !!legalEntityFormId, + }); + + // Fetch preview when mode=mine and legalEntityTaskId changes + const fetchPreview = useCallback(async () => { + if (mode !== 'mine' || !config || !legalEntityTaskId || !formId || !fieldId) { + setPreview(''); + return; + } + + setLoading(true); + setError(''); + try { + const response = await apiRequest('POST', '/api/tasks/preview-contract-number', { + formId, + legalEntityTaskId, + fieldId, + }); + const data = await response.json(); + if (data.success) { + setPreview(data.preview); + } else { + setPreview(''); + setError(data.error || ''); + } + } catch (err) { + setPreview(''); + setError('Ошибка загрузки preview'); + } finally { + setLoading(false); + } + }, [mode, config, legalEntityTaskId, formId, fieldId]); + + useEffect(() => { + fetchPreview(); + }, [fetchPreview]); + + const handleModeChange = (newMode: 'mine' | 'customer') => { + setMode(newMode); + if (newMode === 'mine') { + onChange({ mode: 'mine', value: '', legalEntityTaskId: parsed.legalEntityTaskId }); + } else { + onChange({ mode: 'customer', value: customerValue, legalEntityTaskId: parsed.legalEntityTaskId }); + } + }; + + const handleCustomerChange = (val: string) => { + setCustomerValue(val); + onChange({ mode: 'customer', value: val, legalEntityTaskId: parsed.legalEntityTaskId }); + }; + + const handleLegalEntityChange = (taskId: string) => { + const num = taskId ? parseInt(taskId) : undefined; + onChange({ mode, value: mode === 'mine' ? (parsed.value || '') : customerValue, legalEntityTaskId: num }); + }; + + const isGenerated = !!(parsed.mode === 'mine' && parsed.value && parsed.value !== ''); + + return ( +
    +
    + handleModeChange(checked ? 'mine' : 'customer')} + disabled={disabled || isGenerated} + /> + +
    + + + + {mode === 'customer' ? ( + handleCustomerChange(e.target.value)} + placeholder="Введите шифр заказчика" + disabled={disabled} + /> + ) : ( +
    +
    + + {isGenerated ? ( + {parsed.value} + ) : loading ? ( +
    + + Загрузка... +
    + ) : preview ? ( + {preview} + ) : ( + + {legalEntityTaskId ? 'Не удалось загрузить номер' : 'Выберите юрлицо'} + + )} +
    + {error && ( +

    {error}

    + )} +
    + )} +
    + ); +} diff --git a/client/src/components/CreateBookmarkModal.tsx b/client/src/components/CreateBookmarkModal.tsx new file mode 100644 index 0000000..2074d56 --- /dev/null +++ b/client/src/components/CreateBookmarkModal.tsx @@ -0,0 +1,256 @@ +import { useEffect, useState } from 'react'; +import { BadRequestError } from '@/lib/queryClient'; +import { useToast } from '@/hooks/use-toast'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Input } from '@/components/ui/input'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; +import { useCreateBookmark, useBookmarkFolders } from '@/hooks/useBookmarks'; + +function normalizeUrlForStorage(url: string): string { + if (!url || url.startsWith('/')) return url; + try { + const parsed = new URL(url); + if (parsed.hostname === window.location.hostname) { + return parsed.pathname + parsed.search + parsed.hash; + } + } catch { + // not a valid absolute URL, return as-is + } + return url; +} + +const urlOrPath = z.string().min(1, 'URL обязателен').refine( + (val) => { + if (val.startsWith('/')) return true; + try { new URL(val); return true; } catch { return false; } + }, + { message: 'Укажите путь (/tasks) или полный URL (https://...)' } +); + +type ValidationDetailsArray = { field: string; message: string }[]; +type ValidationDetailsObject = { fieldErrors?: Record }; + +function extractUrlFieldError(serverData: unknown): string | null { + const data = serverData as { details?: ValidationDetailsArray | ValidationDetailsObject } | undefined; + const details = data?.details; + if (Array.isArray(details)) { + const entry = details.find((d) => d.field === 'url'); + return entry?.message ?? null; + } + if (details && !Array.isArray(details)) { + const fieldErrors = (details as ValidationDetailsObject).fieldErrors?.url; + if (Array.isArray(fieldErrors) && fieldErrors.length > 0) return fieldErrors[0]; + } + return null; +} + +const createBookmarkSchema = z.object({ + name: z.string().min(1, 'Название закладки обязательно').max(200), + url: urlOrPath, + folderId: z.number().optional(), +}); + +type CreateBookmarkFormData = z.infer; + +interface CreateBookmarkModalProps { + trigger?: React.ReactNode; + folderId?: number; + defaultUrl?: string; +} + +export function CreateBookmarkModal({ trigger, folderId, defaultUrl }: CreateBookmarkModalProps) { + const [open, setOpen] = useState(false); + const { data: foldersData } = useBookmarkFolders(); + const createBookmark = useCreateBookmark(); + const { toast } = useToast(); + + const form = useForm({ + resolver: zodResolver(createBookmarkSchema), + defaultValues: { + name: '', + url: defaultUrl || '', + folderId: folderId, + }, + }); + + useEffect(() => { + if (open && defaultUrl) { + form.setValue('url', defaultUrl, { shouldValidate: false }); + } + }, [open, defaultUrl, form]); + + const onSubmit = async (data: CreateBookmarkFormData) => { + try { + await createBookmark.mutateAsync({ + name: data.name, + type: 'custom_url', + folderId: data.folderId, + url: normalizeUrlForStorage(data.url), + }); + form.reset(); + setOpen(false); + } catch (error) { + if (error instanceof BadRequestError) { + const urlMessage = extractUrlFieldError(error.serverData); + if (urlMessage !== null) { + form.setError('url', { message: urlMessage }); + } else { + const msg = (error.serverData as { error?: string } | undefined)?.error; + toast({ + title: 'Ошибка', + description: msg || 'Не удалось создать закладку', + variant: 'destructive', + }); + } + return; + } + console.error('Error creating bookmark:', error); + } + }; + + const folders = foldersData?.folders || []; + + const buildFolderOptions = (parentId: number | null = null, level: number = 0): JSX.Element[] => { + return folders + .filter(f => f.parentId === parentId) + .sort((a, b) => a.position - b.position) + .flatMap(folder => { + const indent = ' '.repeat(level); + const options = [ + + {indent}📁 {folder.name} + + ]; + options.push(...buildFolderOptions(folder.id, level + 1)); + return options; + }); + }; + + return ( + + + {trigger} + + + + Создать закладку + + Добавьте новую закладку для быстрого доступа к важным разделам + + + +
    + + ( + + Название закладки + + + + + + )} + /> + + ( + + Ссылка + + + +

    + Путь приложения (/forms/5/tasks) или внешний URL — система откроет нужный способ автоматически +

    + +
    + )} + /> + + ( + + Папка (необязательно) + + + + )} + /> + + + + + + + +
    +
    + ); +} diff --git a/client/src/components/CreateFolderModal.tsx b/client/src/components/CreateFolderModal.tsx new file mode 100644 index 0000000..ed3eec1 --- /dev/null +++ b/client/src/components/CreateFolderModal.tsx @@ -0,0 +1,120 @@ +import { useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { useCreateBookmarkFolder } from '@/hooks/useBookmarks'; +import { FolderPlus } from 'lucide-react'; + +const createFolderSchema = z.object({ + name: z.string().min(1, 'Название папки обязательно').max(200, 'Название слишком длинное'), +}); + +type CreateFolderForm = z.infer; + +interface CreateFolderModalProps { + parentId?: number | null; + trigger?: React.ReactNode; +} + +export function CreateFolderModal({ parentId = null, trigger }: CreateFolderModalProps) { + const [open, setOpen] = useState(false); + const createFolder = useCreateBookmarkFolder(); + + const form = useForm({ + resolver: zodResolver(createFolderSchema), + defaultValues: { + name: '', + }, + }); + + const onSubmit = (data: CreateFolderForm) => { + createFolder.mutate( + { + name: data.name, + parentId: parentId || undefined, + position: 0, + }, + { + onSuccess: () => { + setOpen(false); + form.reset(); + }, + } + ); + }; + + return ( + + + {trigger || ( + + )} + + + + + {parentId ? 'Создать подпапку' : 'Создать папку'} + + +
    + + ( + + Название папки + + + + + + )} + /> +
    + + +
    + + +
    +
    + ); +} \ No newline at end of file diff --git a/client/src/components/CreateTaskDialog.tsx b/client/src/components/CreateTaskDialog.tsx new file mode 100644 index 0000000..2a2adce --- /dev/null +++ b/client/src/components/CreateTaskDialog.tsx @@ -0,0 +1,35 @@ +import { Dialog, DialogContent } from '@/components/ui/dialog'; +import { CreateTaskFormContent } from '@/components/CreateTaskFormContent'; + +export interface CreateTaskDialogProps { + formId: number; + open: boolean; + onOpenChange: (open: boolean) => void; + onCreated?: (task: { id: number; title: string }) => void; + prefillValues?: Record; +} + +export function CreateTaskDialog({ + formId, + open, + onOpenChange, + onCreated, + prefillValues, +}: CreateTaskDialogProps) { + return ( + + + { + onCreated?.(task); + onOpenChange(false); + }} + onCancel={() => onOpenChange(false)} + /> + + + ); +} diff --git a/client/src/components/CreateTaskFormContent.tsx b/client/src/components/CreateTaskFormContent.tsx new file mode 100644 index 0000000..891e716 --- /dev/null +++ b/client/src/components/CreateTaskFormContent.tsx @@ -0,0 +1,788 @@ +import { useState, useEffect, useMemo, useRef, useCallback } from 'react'; +import { useQuery, useMutation } from '@tanstack/react-query'; +import { useForm, useWatch } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; +import { apiRequest, queryClient, BadRequestError } from '@/lib/queryClient'; +import { useToast } from '@/hooks/use-toast'; +import { parseOrgDateTime } from '@/lib/timezone'; +import { normalizeSelectOptions } from '@/lib/fieldOptions'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'; +import { Textarea } from '@/components/ui/textarea'; +import { DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'; +import CompanyFieldInput, { DadataCompany } from '@/components/CompanyFieldInput'; +import GeoField from '@/components/GeoField'; +import ContractNumberField from '@/components/ContractNumberField'; +import TableFieldSelect from '@/components/TableFieldSelect'; +import TableFieldMultiSelect from '@/components/TableFieldMultiSelect'; +import ContactField from '@/components/ContactField'; +import { SearchableSelect } from '@/components/fields/SearchableSelect'; +import { SearchableMultiSelect } from '@/components/fields/SearchableMultiSelect'; +import { + TaskFieldSelect, + UserFieldSelect, + FileUploadField, + FormFieldData, + UserData, +} from '@/components/task-form-fields'; +import { useAuth } from '@/hooks/useAuth'; +import { isAdmin } from '@/lib/permissions'; +import { + computeFieldState, + buildRequiredConditionContext, + getAllConditionFields, + validateRequiredFieldsDetailed, + type FieldConditionContext, + type ClientFormField, + type FieldState, +} from '@/lib/fieldConditions'; + +interface FormData { + id: number; + name: string; + description?: string; + isActive: boolean; + titleTemplate?: string | null; + hiddenSystemFields?: string[] | null; +} + +interface FormStatusData { + id: number; + name: string; + color: string; + isInitial: boolean; + isFinal: boolean; + position: number; +} + +interface OrgRole { + id: number; + name: string; + parentId: number | null; +} + +// Схема для создания задачи (название генерируется автоматически из шаблона формы) +const createTaskSchema = z.object({ + assignedTo: z.coerce.number().optional(), + dueDate: z.string().optional(), + dueTime: z.string().optional(), +}).catchall(z.any()); // Позволяет дополнительные поля + +export interface CreateTaskFormContentProps { + formId: number; + onSuccess?: (task: { id: number; title: string }) => void; + onCancel?: () => void; + prefillValues?: Record; + open?: boolean; +} + +export function CreateTaskFormContent({ + formId, + onSuccess, + onCancel, + prefillValues, + open = true, +}: CreateTaskFormContentProps) { + const { toast } = useToast(); + const { user: currentUser } = useAuth(); + const hasAppliedPrefillRef = useRef(false); + const wasOpenRef = useRef(false); + + // Загрузка данных формы + const { data: formData } = useQuery<{ form: FormData }>({ + queryKey: ['/api/forms', formId], + enabled: !!formId, + staleTime: 5 * 60 * 1000, + gcTime: 10 * 60 * 1000, + retry: false, + }); + + // Загрузка полей формы + const { data: fieldsData } = useQuery<{ fields: FormFieldData[] }>({ + queryKey: ['/api/forms', formId, 'fields'], + enabled: !!formId, + staleTime: 5 * 60 * 1000, + gcTime: 10 * 60 * 1000, + }); + + // Загрузка статусов формы + const { data: statusesData } = useQuery<{ statuses: FormStatusData[] }>({ + queryKey: ['/api/forms', formId, 'statuses'], + enabled: !!formId, + staleTime: 5 * 60 * 1000, + gcTime: 10 * 60 * 1000, + }); + + // Загрузка пользователей для назначения + const { data: usersData } = useQuery<{ users: UserData[] }>({ + queryKey: ['/api/users/list'], + staleTime: 5 * 60 * 1000, + gcTime: 10 * 60 * 1000, + }); + + // Загрузка ролей организации (только admin) + const { data: orgRolesData } = useQuery<{ success: boolean; roles: OrgRole[] }>({ + queryKey: ['/api/roles'], + enabled: isAdmin(currentUser), + staleTime: 60 * 1000, + }); + + const form = useForm>({ + resolver: zodResolver(createTaskSchema), + defaultValues: { + assignedTo: undefined, + dueDate: '', + dueTime: '', + }, + }); + + // Применяем предзаполненные значения только при первом открытии диалога. + useEffect(() => { + if (open && prefillValues && Object.keys(prefillValues).length > 0 && !hasAppliedPrefillRef.current && fieldsData?.fields) { + hasAppliedPrefillRef.current = true; + const fieldByCode = new Map(fieldsData.fields.map(f => [f.code, f])); + for (const [prefillName, value] of Object.entries(prefillValues)) { + // Поддерживаем оба формата: prefill_field_3 и prefill_customField_185 + let targetName = prefillName; + if (prefillName.startsWith('field_')) { + const field = fieldByCode.get(prefillName); + if (field) { + targetName = `customField_${field.id}`; + } + } + form.setValue(targetName as any, value); + } + } + }, [open, prefillValues, fieldsData?.fields, form]); + + // Сбрасываем форму при закрытии диалога + useEffect(() => { + if (wasOpenRef.current && !open) { + form.reset(); + form.clearErrors(); + setSelectedCompanyFields(new Set()); + } + wasOpenRef.current = open; + }, [open, form]); + + // Отслеживаем, какие company-поля были выбраны из подсказок (а не набраны вручную) + const [selectedCompanyFields, setSelectedCompanyFields] = useState>(new Set()); + + const formInfo = formData?.form; + const fields: FormFieldData[] = (fieldsData?.fields || []) + .slice() + .sort((a, b) => (a.displayOrder ?? 0) - (b.displayOrder ?? 0)); + const statuses: FormStatusData[] = statusesData?.statuses || []; + const users: UserData[] = usersData?.users || []; + const orgRoles: OrgRole[] = orgRolesData?.roles || []; + + // Коды полей, которые автозаполняются через DaData — в диалоге создания не показываем + const companyAutofillTargets = useMemo(() => { + const targets = new Set(); + (fieldsData?.fields ?? []).forEach(f => { + if (f.type === 'company' && f.companyAutofill && typeof f.companyAutofill === 'object') { + Object.values(f.companyAutofill as Record).forEach(code => { + if (code && code !== 'none') targets.add(code); + }); + } + }); + return targets; + }, [fieldsData?.fields]); + + // Имена полей company, видимых в диалоге создания (для блокировки кнопки «Создать») + const visibleCompanyFieldNames = useMemo(() => { + return (fieldsData?.fields ?? []) + .filter(f => f.type === 'company' && !companyAutofillTargets.has(f.code)) + .map(f => `customField_${f.id}`); + }, [fieldsData?.fields, companyAutofillTargets]); + + // Отслеживаем все значения формы реактивно — нужно для блокировки кнопки по company-полям + const allWatchedValues = useWatch({ control: form.control }); + const companyNotFilled = visibleCompanyFieldNames.length > 0 && + visibleCompanyFieldNames.some(name => !allWatchedValues[name] || !selectedCompanyFields.has(name)); + + // Контекст для проверки условной обязательности полей при создании задачи + const initialStatusForValidation = useMemo(() => statuses.find((s) => s.isInitial), [statuses]); + const watchedFieldValues = useMemo(() => { + const values: Array<{ fieldId: number; value: unknown }> = []; + if (!fields) return values; + for (const f of fields) { + const v = allWatchedValues[`customField_${f.id}`]; + values.push({ fieldId: f.id, value: v }); + } + return values; + }, [allWatchedValues, fields]); + const allConditionFields = useMemo(() => getAllConditionFields(fields || []), [fields]); + const creationConditionContext = useMemo(() => + buildRequiredConditionContext( + users || [], + orgRoles || [], + true, + statuses.map((s) => ({ id: s.id, name: s.name, position: s.position, isFinal: s.isFinal })) + ), [users, orgRoles, statuses]); + + const getFieldStateForCreate = useCallback((formField: FormFieldData): FieldState => { + return computeFieldState( + formField as ClientFormField, + { currentStatusId: initialStatusForValidation?.id }, + watchedFieldValues, + allConditionFields as ClientFormField[], + creationConditionContext + ); + }, [initialStatusForValidation, watchedFieldValues, allConditionFields, creationConditionContext]); + + const isFieldRequiredForCreate = useCallback((formField: FormFieldData) => { + return getFieldStateForCreate(formField).required; + }, [getFieldStateForCreate]); + + const isFieldImmutableForCreate = useCallback((formField: FormFieldData) => { + return getFieldStateForCreate(formField).immutable; + }, [getFieldStateForCreate]); + + const isFieldVisibleForCreate = useCallback((formField: FormFieldData) => { + return getFieldStateForCreate(formField).visible; + }, [getFieldStateForCreate]); + + const createTaskMutation = useMutation({ + mutationFn: async (data: z.infer) => { + const initialStatus = statuses.find(s => s.isInitial); + if (!initialStatus) { + throw new Error('Не найден начальный статус для формы'); + } + + // Разделяем основные данные и кастомные поля + const { assignedTo, dueDate, dueTime, ...rest } = data; + + // Собираем кастомные поля + const customFields: Record = {}; + Object.entries(rest).forEach(([key, value]) => { + if (key.startsWith('customField_') && value !== undefined && value !== '') { + customFields[key] = value; + } + }); + + // Собираем dueDate: если указано только дата — all-day (передаём строку даты); + // если указано и время — передаём полный ISO в часовом поясе организации (Europe/Moscow). + let parsedDueDate: string | undefined; + if (dueDate && dueDate.trim() !== '') { + if (dueTime && dueTime.trim() !== '') { + parsedDueDate = parseOrgDateTime(dueDate, dueTime).toISOString(); + } else { + parsedDueDate = dueDate; // date-only string → сервер сохранит как 00:00 UTC (all-day) + } + } + + // Название: бэкенд заменит через titleTemplate; fallback = имя формы + const taskData = { + title: formInfo?.name ?? 'Новая запись', + assignedTo: assignedTo || undefined, + dueDate: parsedDueDate, + formId, + currentStatusId: initialStatus.id, + ...(Object.keys(customFields).length > 0 && { customFields }) + }; + + // Проверяем условную обязательность полей перед созданием + const missingRequired = validateRequiredFieldsDetailed( + { currentStatusId: initialStatus.id }, + fields || [], + watchedFieldValues, + users || [], + orgRoles || [], + true, + statuses.map((s) => ({ id: s.id, name: s.name, position: s.position, isFinal: s.isFinal })) + ); + if (missingRequired.length > 0) { + throw new BadRequestError({ + error: `Заполните обязательные поля: ${missingRequired.map((e) => e.message).join(', ')}`, + fields: missingRequired, + }); + } + + const res = await apiRequest('POST', `/api/forms/${formId}/tasks`, taskData); + const result = (await res.json()) as { success: boolean; task: { id: number; title: string } }; + return result.task; + }, + onSuccess: (createdTask) => { + queryClient.invalidateQueries({ queryKey: ['/api/forms', formId, 'tasks', 'with-fields'] }); + form.reset(); + form.clearErrors(); + setSelectedCompanyFields(new Set()); + toast({ + title: 'Задача создана', + description: 'Новая задача успешно добавлена', + }); + onSuccess?.(createdTask); + }, + onError: (error: any) => { + // Подсвечиваем незаполненные обязательные поля + if (error instanceof BadRequestError && error.serverData && typeof error.serverData === 'object') { + const serverFields = (error.serverData as { fields?: Array<{ fieldId?: number; message?: string }> }).fields; + if (Array.isArray(serverFields)) { + for (const f of serverFields) { + if (f.fieldId != null && f.message) { + form.setError(`customField_${f.fieldId}` as any, { type: 'manual', message: f.message }); + } + } + } + } + toast({ + title: 'Ошибка создания задачи', + description: error.message || 'Произошла ошибка при создании задачи', + variant: 'destructive', + }); + }, + }); + + return ( + <> + + Создать новую запись + + {formInfo?.titleTemplate + ? `Название сформируется автоматически по шаблону` + : `Название будет: «${formInfo?.name}»`} + + + +
    +
    + createTaskMutation.mutate(data))} className="space-y-3"> + + {!formInfo?.hiddenSystemFields?.includes('assignedTo') && ( + /* Ответственный */ + ( + + Ответственный + + field.onChange(value === '' ? undefined : parseInt(value))} + /> + + + + )} + /> + )} + + {!formInfo?.hiddenSystemFields?.includes('dueDate') && ( + /* Срок выполнения */ + ( + + Срок выполнения + +
    + + +
    +
    + +
    + )} + /> + )} + + {/* Кастомные поля формы */} + {fields && fields.length > 0 && ( + <> +
    + {fields.filter((formField) => isFieldVisibleForCreate(formField) && !companyAutofillTargets.has(formField.code)).map((formField) => { + const fieldName = `customField_${formField.id}`; + const isImmutable = isFieldImmutableForCreate(formField); + + return ( + ( + + + {formField.name} + {isFieldRequiredForCreate(formField) && *} + + +
    + {(() => { + switch (formField.type) { + case 'text': + return ( + + ); + case 'number': + return ( + field.onChange(e.target.value.replace(/\s/g, ''))} + data-testid={`input-custom-${formField.code}`} + className="h-8" + /> + ); + case 'date': + case 'datetime': { + const isProlongation = field.value === '__auto_prolongation__'; + return ( +
    + field.onChange(e.target.value)} + disabled={isProlongation} + data-testid={`input-custom-${formField.code}`} + className="h-8" + /> + {formField.autoProlongation && ( + + )} +
    + ); + } + case 'textarea': + return ( +