fix(number-fields): избегаем потери точности длинных чисел
- number-поля теперь рендерятся как text + inputMode=numeric, чтобы браузер не округлял значения через input type=number - пробелы при вставке в number-поля удаляются - бэкенд нормализует значения number-полей в строку перед сохранением - добавлен хелпер normalizeFieldValueForStorage Closes: искажение расчётного счёта и других длинных числовых полей
BIN
.canvas/assets/asset_-1689877073.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
.canvas/assets/asset_-893763085.png
Normal file
|
After Width: | Height: | Size: 49 KiB |
BIN
.canvas/assets/asset_103195470.png
Normal file
|
After Width: | Height: | Size: 61 KiB |
18
.dockerignore
Normal file
@@ -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
|
||||
113
.env.example
Normal file
@@ -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
|
||||
71
.env.local.example
Normal file
@@ -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
|
||||
53
.gitattributes
vendored
Normal file
@@ -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
|
||||
21
.githooks/pre-push
Normal file
@@ -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..."
|
||||
57
.github/workflows/build-document-worker.yml
vendored
Normal file
@@ -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
|
||||
171
.github/workflows/deploy.yml
vendored
Normal file
@@ -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"
|
||||
39
.github/workflows/pr-check.yml
vendored
Normal file
@@ -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
|
||||
31
.gitignore
vendored
Normal file
@@ -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/**
|
||||
54
.replit
Normal file
@@ -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
|
||||
280
DOCKER.md
Normal file
@@ -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_<name>.sql`.
|
||||
|
||||
3. **Закоммитьте оба файла** — изменённый `shared/schema.ts` и новый SQL-файл миграции:
|
||||
```bash
|
||||
git add shared/schema.ts migrations/
|
||||
git commit -m "feat: add <description>"
|
||||
```
|
||||
|
||||
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` |
|
||||
45
Dockerfile
Normal file
@@ -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"]
|
||||
|
||||
165
FIELD_TYPES.md
Normal file
@@ -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`) синхронизируется **автоматически**.
|
||||
7
LICENSE
Normal file
@@ -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.
|
||||
89
README.md
Normal file
@@ -0,0 +1,89 @@
|
||||
# iistwin
|
||||
|
||||
[](https://github.com/iisultanov/iistwin/actions/workflows/ci.yml)
|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
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).
|
||||
70
SESSION_NOTES.md
Normal file
@@ -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
|
||||
141
_tablefield_orig.txt
Normal file
@@ -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<HTMLInputElement>(null);
|
||||
|
||||
if (!tableId) {
|
||||
return <div className="text-sm text-muted-foreground">Таблица не настроена</div>;
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <Input disabled className="h-8" placeholder="Загрузка..." />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <div className="text-sm text-destructive">Нет доступа к таблице</div>;
|
||||
}
|
||||
|
||||
const table = tableData?.table;
|
||||
if (!table || !table.rows?.length) {
|
||||
return <div className="text-sm text-muted-foreground">Таблица пуста</div>;
|
||||
}
|
||||
|
||||
// Находим индекс столбца для отображения, с 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 (
|
||||
<Popover open={isOpen} onOpenChange={setIsOpen}>
|
||||
<PopoverAnchor asChild>
|
||||
<div className="relative w-full">
|
||||
<Input
|
||||
ref={inputRef}
|
||||
className="h-8"
|
||||
placeholder={formField.placeholder || 'Выберите значение...'}
|
||||
value={isOpen ? searchTerm : (selectedDisplay || '')}
|
||||
onChange={(e) => {
|
||||
setSearchTerm(e.target.value);
|
||||
setIsOpen(true);
|
||||
}}
|
||||
onFocus={() => {
|
||||
setSearchTerm('');
|
||||
setIsOpen(true);
|
||||
}}
|
||||
data-testid={`select-table-${formField.code}`}
|
||||
/>
|
||||
</div>
|
||||
</PopoverAnchor>
|
||||
<PopoverContent
|
||||
className="p-0"
|
||||
style={{ width: 'var(--radix-popper-anchor-width)' }}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
onInteractOutside={(e) => {
|
||||
if (inputRef.current && inputRef.current.contains(e.target as Node)) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ul className="max-h-60 overflow-auto py-1">
|
||||
<li
|
||||
className="cursor-pointer px-3 py-2 hover:bg-accent text-sm text-muted-foreground"
|
||||
onMouseDown={(e) => { e.preventDefault(); handleSelect(''); }}
|
||||
>
|
||||
—
|
||||
</li>
|
||||
{filtered.map(row => (
|
||||
<li
|
||||
key={row.id}
|
||||
className="cursor-pointer px-3 py-2 hover:bg-accent"
|
||||
onMouseDown={(e) => { e.preventDefault(); handleSelect(row.id.toString()); }}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Table className="w-3 h-3 text-muted-foreground" />
|
||||
{row.values[displayColumnIndex!] || row.values[0]}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
<li className="px-3 py-2 text-sm text-muted-foreground">Нет совпадений</li>
|
||||
)}
|
||||
</ul>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
// Компонент выбора связанной задачи из другой формы
|
||||
function TaskFieldSelect({ formField, value, onChange }: {
|
||||
|
||||
|
||||
37
attached_assets/1_module_1767958951103.json
Normal file
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
BIN
attached_assets/2025-09-16_13-52-59_1758020005216.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
31
attached_assets/Pasted---1766085687413_1766085687414.txt
Normal file
@@ -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 используется?
|
||||
@@ -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
|
||||
@@ -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 <token>`, а не из `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 при масштабировании`.
|
||||
@@ -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 и он сразу пойдёт правильным путём!
|
||||
@@ -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.
|
||||
42
attached_assets/Pasted--UI--1766092477866_1766092477867.txt
Normal file
@@ -0,0 +1,42 @@
|
||||
Нужно внести правки в приложение (UI + логика), без регрессий существующих сценариев “редактор форм / просмотр задачи / создание задачи”.
|
||||
|
||||
ПРАВКА 1 — поле “Связь” в редакторе форм
|
||||
Где: Редактор форм → добавление/настройка поля типа “Связь с задачей” (см. скрин 1).
|
||||
Текущее поведение: в редакторе форм предлагается “Связь с задачей”.
|
||||
Ожидаемое поведение: в редакторе форм это должно быть “Связь с формой” (выбор формы-родителя).
|
||||
Логика: связь настраивается на уровне формы (выбрали родительскую форму), а уже при создании/редактировании конкретной задачи пользователь выбирает конкретную родительскую задачу (из задач выбранной родительской формы).
|
||||
Критерии приёмки:
|
||||
- В редакторе форм в поле связи выбирается форма-родитель (а не задача).
|
||||
- В карточке/редактировании задачи появляется выбор родительской задачи, и список ограничен задачами выбранной формы-родителя.
|
||||
|
||||
ПРАВКА 2 — подзадачи вынести на вкладку
|
||||
Где: Просмотр задачи (см. скрин 2).
|
||||
Текущее поведение: есть кнопка “Добавить подзадачу”, и подзадачи отображаются не так, как нужно.
|
||||
Ожидаемое поведение:
|
||||
- Кнопку “Добавить подзадачу” убрать.
|
||||
- Подзадачи показывать на отдельной вкладке “Подзадачи”.
|
||||
- Вкладка создаётся/появляется автоматически, только если у текущей задачи есть подзадачи (прямые или в дереве — как реализовано в модели данных).
|
||||
- На вкладке “Подзадачи” показать древовидный список от текущей задачи вниз.
|
||||
- В дереве сделать группировку по формам подзадач (группы с “человекочитаемыми” названиями форм).
|
||||
Критерии приёмки:
|
||||
- На странице задачи нет кнопки “Добавить подзадачу”.
|
||||
- Если подзадач нет — вкладки “Подзадачи” нет.
|
||||
- Если подзадачи есть — вкладка есть, внутри дерево, и элементы сгруппированы по форме подзадачи.
|
||||
|
||||
ПРАВКА 3 — действия для поля типа “Кнопка” в редакторе форм
|
||||
Где: Редактор форм → поле типа “Кнопка”.
|
||||
Нужно добавить настройку “Действие кнопки при нажатии” (actionType) с вариантами:
|
||||
1) Открыть URL:
|
||||
- В настройках поля указываю URL (строка).
|
||||
- При нажатии кнопки открывается этот URL (в новой вкладке или в текущей — выбери один вариант и сделай единообразно).
|
||||
2) Создать задачу по выбранной форме:
|
||||
- В настройках поля выбираю форму из выпадающего списка.
|
||||
- Список должен поддерживать ввод текста + онлайн-фильтрацию (набрал “дог” → показались формы “Договор…”, и т.д.).
|
||||
- При нажатии кнопки запускается создание задачи по выбранной форме (переход на экран создания или модалка — выбери подходящий текущей архитектуре вариант).
|
||||
Критерии приёмки:
|
||||
- В редакторе форм у кнопки можно выбрать тип действия и настроить параметры (URL или форма).
|
||||
- По нажатию кнопки действие выполняется корректно.
|
||||
|
||||
Примечания:
|
||||
- Скрин 1 и скрин 2 прикладываю в задаче (ориентируйся на них).
|
||||
- Если для правок нужны изменения в API/БД — добавь миграции и обнови соответствующие места в коде.
|
||||
42
attached_assets/Pasted--UI--1766092483961_1766092483962.txt
Normal file
@@ -0,0 +1,42 @@
|
||||
Нужно внести правки в приложение (UI + логика), без регрессий существующих сценариев “редактор форм / просмотр задачи / создание задачи”.
|
||||
|
||||
ПРАВКА 1 — поле “Связь” в редакторе форм
|
||||
Где: Редактор форм → добавление/настройка поля типа “Связь с задачей” (см. скрин 1).
|
||||
Текущее поведение: в редакторе форм предлагается “Связь с задачей”.
|
||||
Ожидаемое поведение: в редакторе форм это должно быть “Связь с формой” (выбор формы-родителя).
|
||||
Логика: связь настраивается на уровне формы (выбрали родительскую форму), а уже при создании/редактировании конкретной задачи пользователь выбирает конкретную родительскую задачу (из задач выбранной родительской формы).
|
||||
Критерии приёмки:
|
||||
- В редакторе форм в поле связи выбирается форма-родитель (а не задача).
|
||||
- В карточке/редактировании задачи появляется выбор родительской задачи, и список ограничен задачами выбранной формы-родителя.
|
||||
|
||||
ПРАВКА 2 — подзадачи вынести на вкладку
|
||||
Где: Просмотр задачи (см. скрин 2).
|
||||
Текущее поведение: есть кнопка “Добавить подзадачу”, и подзадачи отображаются не так, как нужно.
|
||||
Ожидаемое поведение:
|
||||
- Кнопку “Добавить подзадачу” убрать.
|
||||
- Подзадачи показывать на отдельной вкладке “Подзадачи”.
|
||||
- Вкладка создаётся/появляется автоматически, только если у текущей задачи есть подзадачи (прямые или в дереве — как реализовано в модели данных).
|
||||
- На вкладке “Подзадачи” показать древовидный список от текущей задачи вниз.
|
||||
- В дереве сделать группировку по формам подзадач (группы с “человекочитаемыми” названиями форм).
|
||||
Критерии приёмки:
|
||||
- На странице задачи нет кнопки “Добавить подзадачу”.
|
||||
- Если подзадач нет — вкладки “Подзадачи” нет.
|
||||
- Если подзадачи есть — вкладка есть, внутри дерево, и элементы сгруппированы по форме подзадачи.
|
||||
|
||||
ПРАВКА 3 — действия для поля типа “Кнопка” в редакторе форм
|
||||
Где: Редактор форм → поле типа “Кнопка”.
|
||||
Нужно добавить настройку “Действие кнопки при нажатии” (actionType) с вариантами:
|
||||
1) Открыть URL:
|
||||
- В настройках поля указываю URL (строка).
|
||||
- При нажатии кнопки открывается этот URL (в новой вкладке или в текущей — выбери один вариант и сделай единообразно).
|
||||
2) Создать задачу по выбранной форме:
|
||||
- В настройках поля выбираю форму из выпадающего списка.
|
||||
- Список должен поддерживать ввод текста + онлайн-фильтрацию (набрал “дог” → показались формы “Договор…”, и т.д.).
|
||||
- При нажатии кнопки запускается создание задачи по выбранной форме (переход на экран создания или модалка — выбери подходящий текущей архитектуре вариант).
|
||||
Критерии приёмки:
|
||||
- В редакторе форм у кнопки можно выбрать тип действия и настроить параметры (URL или форма).
|
||||
- По нажатию кнопки действие выполняется корректно.
|
||||
|
||||
Примечания:
|
||||
- Скрин 1 и скрин 2 прикладываю в задаче (ориентируйся на них).
|
||||
- Если для правок нужны изменения в API/БД — добавь миграции и обнови соответствующие места в коде.
|
||||
@@ -0,0 +1,26 @@
|
||||
<thinking> 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.
|
||||
</thinking>
|
||||
247
attached_assets/Pasted-1-f-1765908057124_1765908057128.txt
Normal file
@@ -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 (за гибкость и организованность)
|
||||
|
||||
Главное правило: Каждый пиксель должен работать на функциональность. Никакого лишнего пространства, но и никакого визуального хаоса.
|
||||
@@ -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<TableColumnDef>) => void' is not assignable to type '(tabIndex: number, colIndex: number, updates: Partial<import("/home/runner/work/TaskTitan/TaskTitan/client/src/components/tabs/types").TableColumnDef>) => void'.
|
||||
Lint & Build: client/src/pages/FormEditor.tsx#L2206
|
||||
Type '(index: number, updates: Partial<FormTab>) => void' is not assignable to type '(index: number, updates: Partial<import("/home/runner/work/TaskTitan/TaskTitan/client/src/components/tabs/types").FormTab>) => 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/
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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.<anonymous> (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.<anonymous> (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.<anonymous> (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":[]}
|
||||
BIN
attached_assets/TASKTITAN_AUDIT_REPORT_1778659557017.docx
Normal file
BIN
attached_assets/TASKTITAN_REPLIT_PROMPT_v2_1778675591081.docx
Normal file
1265
attached_assets/detailed-prompt-complete_1757967325810.md
Normal file
518
attached_assets/detailed-prompt-complete_1757999077295.md
Normal file
@@ -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...**
|
||||
518
attached_assets/detailed-prompt-complete_1758010511902.md
Normal file
@@ -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...**
|
||||
BIN
attached_assets/image_1758006135951.png
Normal file
|
After Width: | Height: | Size: 88 KiB |
BIN
attached_assets/image_1758009667119.png
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
attached_assets/image_1758017517992.png
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
attached_assets/image_1758017570911.png
Normal file
|
After Width: | Height: | Size: 51 KiB |
BIN
attached_assets/image_1758021659230.png
Normal file
|
After Width: | Height: | Size: 47 KiB |
BIN
attached_assets/image_1758026138825.png
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
attached_assets/image_1758042689913.png
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
attached_assets/image_1758045638960.png
Normal file
|
After Width: | Height: | Size: 101 KiB |
BIN
attached_assets/image_1758045704829.png
Normal file
|
After Width: | Height: | Size: 114 KiB |
BIN
attached_assets/image_1758045938600.png
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
attached_assets/image_1758046292803.png
Normal file
|
After Width: | Height: | Size: 49 KiB |
BIN
attached_assets/image_1758051618053.png
Normal file
|
After Width: | Height: | Size: 136 KiB |
BIN
attached_assets/image_1758051652835.png
Normal file
|
After Width: | Height: | Size: 139 KiB |
BIN
attached_assets/image_1758051721522.png
Normal file
|
After Width: | Height: | Size: 144 KiB |
BIN
attached_assets/image_1758051838748.png
Normal file
|
After Width: | Height: | Size: 129 KiB |
BIN
attached_assets/image_1758051888537.png
Normal file
|
After Width: | Height: | Size: 129 KiB |
BIN
attached_assets/image_1758055935161.png
Normal file
|
After Width: | Height: | Size: 133 KiB |
BIN
attached_assets/image_1758055993698.png
Normal file
|
After Width: | Height: | Size: 120 KiB |
BIN
attached_assets/image_1758056595609.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
attached_assets/image_1758056656964.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
attached_assets/image_1758060339604.png
Normal file
|
After Width: | Height: | Size: 75 KiB |
BIN
attached_assets/image_1758060464999.png
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
attached_assets/image_1758094961493.png
Normal file
|
After Width: | Height: | Size: 66 KiB |
BIN
attached_assets/image_1758097316062.png
Normal file
|
After Width: | Height: | Size: 84 KiB |
BIN
attached_assets/image_1758097487033.png
Normal file
|
After Width: | Height: | Size: 61 KiB |
BIN
attached_assets/image_1758104497005.png
Normal file
|
After Width: | Height: | Size: 73 KiB |
BIN
attached_assets/image_1758104522615.png
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
attached_assets/image_1758104561446.png
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
attached_assets/image_1758113228355.png
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
attached_assets/image_1758113427325.png
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
attached_assets/image_1758114654033.png
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
attached_assets/image_1758115310557.png
Normal file
|
After Width: | Height: | Size: 55 KiB |
BIN
attached_assets/image_1758128362968.png
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
attached_assets/image_1758130168543.png
Normal file
|
After Width: | Height: | Size: 69 KiB |
BIN
attached_assets/image_1758130864677.png
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
attached_assets/image_1758132120516.png
Normal file
|
After Width: | Height: | Size: 97 KiB |
BIN
attached_assets/image_1758135051029.png
Normal file
|
After Width: | Height: | Size: 53 KiB |
BIN
attached_assets/image_1758135070763.png
Normal file
|
After Width: | Height: | Size: 65 KiB |
BIN
attached_assets/image_1758139281387.png
Normal file
|
After Width: | Height: | Size: 84 KiB |
BIN
attached_assets/image_1758139690656.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
attached_assets/image_1758139814483.png
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
attached_assets/image_1758140061120.png
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
attached_assets/image_1758140138328.png
Normal file
|
After Width: | Height: | Size: 112 KiB |
BIN
attached_assets/image_1758146720691.png
Normal file
|
After Width: | Height: | Size: 75 KiB |
BIN
attached_assets/image_1758147014575.png
Normal file
|
After Width: | Height: | Size: 69 KiB |
BIN
attached_assets/image_1758147414364.png
Normal file
|
After Width: | Height: | Size: 6.8 KiB |
BIN
attached_assets/image_1758148032195.png
Normal file
|
After Width: | Height: | Size: 72 KiB |
BIN
attached_assets/image_1758216462840.png
Normal file
|
After Width: | Height: | Size: 73 KiB |
BIN
attached_assets/image_1758216496749.png
Normal file
|
After Width: | Height: | Size: 70 KiB |
BIN
attached_assets/image_1758217793501.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
attached_assets/image_1758218311090.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
attached_assets/image_1758221156894.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
attached_assets/image_1758221468247.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
attached_assets/image_1758221573698.png
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
attached_assets/image_1758221818140.png
Normal file
|
After Width: | Height: | Size: 5.4 KiB |
BIN
attached_assets/image_1758226445432.png
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
attached_assets/image_1758227494950.png
Normal file
|
After Width: | Height: | Size: 9.7 KiB |
BIN
attached_assets/image_1758227866465.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
attached_assets/image_1758228127217.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
attached_assets/image_1758228382549.png
Normal file
|
After Width: | Height: | Size: 38 KiB |