Compare commits

...

5 Commits

Author SHA1 Message Date
3132ab2fa2 Add cron job functionality and Docker integration 2026-01-31 00:31:43 +03:00
73acc5410f Update documentation to reflect actual implementation 2026-01-31 00:06:46 +03:00
68d0e9a540 delete dangling files 2026-01-30 23:51:24 +03:00
8309a5dc0e Add hide component feature, usage indicators, and Docker support
- Add is_hidden field to hide components from configurator
- Add colored dot indicator showing component usage status:
  - Green: available in configurator
  - Cyan: used as source for meta-articles
  - Gray: hidden from configurator
- Optimize price recalculation with caching and skip unchanged
- Show current lot name during price recalculation
- Add Dockerfile (Alpine-based multi-stage build)
- Add docker-compose.yml and .dockerignore

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 22:49:11 +03:00
Mikhail Chusavitin
48921c699d Add meta component pricing functionality and admin UI enhancements 2026-01-30 20:49:59 +03:00
18 changed files with 1137 additions and 236 deletions

33
.dockerignore Normal file
View File

@@ -0,0 +1,33 @@
# Git
.git
.gitignore
# IDE
.idea
.vscode
*.swp
*.swo
# Build artifacts
server
*.exe
bin/
# Config with secrets
config.yaml
# Documentation
*.md
LICENSE
# Claude
.claude
# Test files
*_test.go
test_*.csv
test_*.xlsx
# Misc
.DS_Store
*.log

View File

@@ -20,7 +20,6 @@ QuoteForge — корпоративный инструмент для конфи
quoteforge/
├── cmd/
│ ├── server/main.go # Main HTTP server
│ ├── priceupdater/main.go # Cron job for price updates & alerts
│ └── importer/main.go # Import metadata from lot table
├── internal/
│ ├── config/config.go # YAML config loading
@@ -89,7 +88,6 @@ CREATE TABLE qt_users (
CREATE TABLE qt_lot_metadata (
lot_name CHAR(255) PRIMARY KEY,
category_id INT,
vendor VARCHAR(50), -- Parsed from lot_name: CPU_AMD_9654 → "AMD"
model VARCHAR(100), -- Parsed: CPU_AMD_9654 → "9654"
specs JSON,
current_price DECIMAL(12,2),
@@ -168,18 +166,21 @@ CREATE TABLE qt_component_usage_stats (
### 1. Part Number Parsing
Extract category, vendor, model from lot_name:
Extract category and model from lot_name:
```go
// "CPU_AMD_9654" → category="CPU", vendor="AMD", model="9654"
// "MB_INTEL_4.Sapphire_2S_32xDDR5" → category="MB", vendor="INTEL", model="4.Sapphire_2S_32xDDR5"
// "MEM_DDR5_64G_5600" → category="MEM", vendor="DDR5", model="64G_5600"
// "GPU_NV_RTX_4090_PCIe" → category="GPU", vendor="NV", model="RTX_4090_PCIe"
// "CPU_AMD_9654" → category="CPU", model="AMD_9654"
// "MB_INTEL_4.Sapphire_2S" → category="MB", model="INTEL_4.Sapphire_2S"
// "MEM_DDR5_64G_5600" → category="MEM", model="DDR5_64G_5600"
// "GPU_NV_RTX_4090_PCIe" → category="GPU", model="NV_RTX_4090_PCIe"
func ParsePartNumber(lotName string) (category, vendor, model string) {
parts := strings.SplitN(lotName, "_", 3)
if len(parts) >= 1 { category = parts[0] }
if len(parts) >= 2 { vendor = parts[1] }
if len(parts) >= 3 { model = parts[2] }
func ParsePartNumber(lotName string) (category, model string) {
parts := strings.SplitN(lotName, "_", 2)
if len(parts) >= 1 {
category = parts[0]
}
if len(parts) >= 2 {
model = parts[1]
}
return
}
```
@@ -233,7 +234,6 @@ Sort by: popularity + price freshness. Components without prices go to the botto
Generate alerts when:
- **high_demand_stale_price** (CRITICAL): >= 5 quotes/month AND price > 60 days old
- **trending_no_price** (HIGH): trend_percent > 50% AND no price set
- **price_spike** (MEDIUM): price increased > 20% from previous period
- **no_recent_quotes** (MEDIUM): popular component, no supplier quotes > 90 days
## API Endpoints
@@ -274,7 +274,6 @@ GET /api/configs/:uuid → get by UUID
PUT /api/configs/:uuid → update
DELETE /api/configs/:uuid → delete
GET /api/configs/:uuid/export → export as JSON
POST /api/configs/import → import from JSON
```
### Pricing Admin (requires role: pricing_admin or admin)
@@ -325,46 +324,60 @@ GET /partials/summary → price summary HTML
# Run development server
go run ./cmd/server
# Run price updater (cron job)
go run ./cmd/priceupdater
# Run importer (one-time setup)
go run ./cmd/importer
# Run cron jobs manually
go run ./cmd/cron -job=alerts # Check and generate alerts
go run ./cmd/cron -job=update-prices # Recalculate all prices
go run ./cmd/cron -job=reset-counters # Reset usage counters
go run ./cmd/cron -job=update-popularity # Update popularity scores
# Build for production
CGO_ENABLED=0 go build -ldflags="-s -w" -o bin/quoteforge ./cmd/server
CGO_ENABLED=0 go build -ldflags="-s -w" -o bin/quoteforge-cron ./cmd/cron
# Run tests
go test ./...
````
## Cron Jobs
QuoteForge now includes automated cron jobs for maintenance tasks. These can be run using the built-in cron functionality in the Docker container.
### Docker Compose Setup
The Docker setup includes a dedicated cron service that runs the following jobs:
- **Alerts check**: Every hour (0 * * * *)
- **Price updates**: Daily at 2 AM (0 2 * * *)
- **Usage counter reset**: Weekly on Sunday at 1 AM (0 1 * * 0)
- **Popularity score updates**: Daily at 3 AM (0 3 * * *)
### Manual Cron Job Execution
You can also run cron jobs manually using the quoteforge-cron binary:
```bash
# Check and generate alerts
go run ./cmd/cron -job=alerts
# Recalculate all prices
go run ./cmd/cron -job=update-prices
# Reset usage counters
go run ./cmd/cron -job=reset-counters
# Update popularity scores
go run ./cmd/cron -job=update-popularity
```
## Dependencies (go.mod)
### Cron Job Details
```go
module git.mchus.pro/mchus/quoteforge
go 1.22
require (
github.com/gin-gonic/gin v1.9.1
github.com/go-sql-driver/mysql v1.7.1
gorm.io/gorm v1.25.5
gorm.io/driver/mysql v1.5.2
github.com/xuri/excelize/v2 v2.8.0
github.com/golang-jwt/jwt/v5 v5.2.0
github.com/google/uuid v1.5.0
golang.org/x/crypto v0.17.0
gopkg.in/yaml.v3 v3.0.1
)
```
## Development Priorities
1. **Phase 1 (MVP):** Project setup, models, component API, basic UI, CSV export
2. **Phase 2:** JWT auth with roles, pricing admin UI, all price methods
3. **Phase 3:** Save/load configs, JSON import/export, XLSX export, cron jobs
4. **Phase 4:** Usage stats, alerts system, dashboard
5. **Phase 5:** Polish, tests, Docker, documentation
- **Alerts check**: Generates alerts for components with high demand and stale prices, trending components without prices, and components with no recent quotes
- **Price updates**: Recalculates prices for all components using configured methods (median, weighted median, average)
- **Usage counter reset**: Resets weekly and monthly usage counters for components
- **Popularity score updates**: Recalculates popularity scores based on supplier quote activity
## Code Style

68
Dockerfile Normal file
View File

@@ -0,0 +1,68 @@
# Build stage
FROM golang:1.24-alpine AS builder
RUN apk add --no-cache git ca-certificates tzdata
WORKDIR /app
# Copy go mod files first for better caching
COPY go.mod go.sum ./
RUN go mod download
# Copy source code
COPY . .
# Build the main binary
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \
-ldflags="-s -w" \
-o /app/quoteforge \
./cmd/server
# Build the cron binary
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \
-ldflags="-s -w" \
-o /app/quoteforge-cron \
./cmd/cron
# Final stage
FROM alpine:3.19
RUN apk add --no-cache ca-certificates tzdata cron
# Create non-root user
RUN adduser -D -g '' appuser
WORKDIR /app
# Copy binary from builder
COPY --from=builder /app/quoteforge .
COPY --from=builder /app/quoteforge-cron .
# Copy cron job configuration
COPY crontab /etc/crontabs/appuser
RUN chmod 0600 /etc/crontabs/appuser
# Create log directory
RUN mkdir -p /var/log/cron
# Copy web templates and static files
COPY --from=builder /app/web ./web
# Copy migrations
COPY --from=builder /app/migrations ./migrations
# Copy example config (actual config should be mounted)
COPY --from=builder /app/config.example.yaml ./config.example.yaml
# Set ownership
RUN chown -R appuser:appuser /app
USER appuser
EXPOSE 8080
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1
ENTRYPOINT ["/app/quoteforge"]

View File

@@ -2,7 +2,7 @@
**Server Configuration & Quotation Tool**
QuoteForge — корпоративный инструмент для конфигурирования серверов и формирования коммерческих предложений. Позволяет быстро собрать спецификацию сервера из каталога компонентов с автоматическим расчётом цен.
QuoteForge — корпоративный инструмент для конфигурирования серверов и формирования коммерческих предложений (КП). Приложение интегрируется с существующей базой данных RFQ_LOG.
![Go Version](https://img.shields.io/badge/Go-1.22+-00ADD8?style=flat&logo=go)
![License](https://img.shields.io/badge/License-Proprietary-red)
@@ -16,7 +16,6 @@ QuoteForge — корпоративный инструмент для конфи
- 💰 **Автоматический расчёт цен** — актуальные цены на основе истории закупок
- 📊 **Экспорт в CSV/XLSX** — готовые спецификации для клиентов
- 💾 **Сохранение конфигураций** — история и шаблоны для повторного использования
- 📤 **Импорт/экспорт JSON** — обмен конфигурациями между пользователями
### Для ценовых администраторов
- 📈 **Умный расчёт цен** — медиана, взвешенная медиана, среднее
@@ -83,23 +82,23 @@ auth:
### 3. Миграции базы данных
```bash
make migrate
go run ./cmd/server -migrate
```
### 4. Импорт метаданных компонентов
```bash
make seed
go run ./cmd/importer
```
### 5. Запуск
```bash
# Development
make run
go run ./cmd/server
# Production
make build
CGO_ENABLED=0 go build -ldflags="-s -w" -o bin/quoteforge ./cmd/server
./bin/quoteforge
```
@@ -120,9 +119,8 @@ docker-compose up -d
```
quoteforge/
├── cmd/
│ ├── server/ # Основной сервер
── priceupdater/ # Cron job обновления цен
│ └── importer/ # Импорт данных
│ ├── server/main.go # Main HTTP server
── importer/main.go # Import metadata from lot table
├── internal/
│ ├── config/ # Конфигурация
│ ├── models/ # GORM модели
@@ -137,7 +135,7 @@ quoteforge/
├── config.yaml # Конфигурация
├── Dockerfile
├── docker-compose.yml
└── Makefile
└── go.mod
```
## Роли пользователей
@@ -165,30 +163,59 @@ GET /api/configs # Сохранённые конфигурации
## Cron Jobs
Добавьте в crontab:
QuoteForge now includes automated cron jobs for maintenance tasks. These can be run using the built-in cron functionality in the Docker container.
### Docker Compose Setup
The Docker setup includes a dedicated cron service that runs the following jobs:
- **Alerts check**: Every hour (0 * * * *)
- **Price updates**: Daily at 2 AM (0 2 * * *)
- **Usage counter reset**: Weekly on Sunday at 1 AM (0 1 * * 0)
- **Popularity score updates**: Daily at 3 AM (0 3 * * *)
To enable cron jobs in Docker, run:
```bash
# Обновление цен — каждую ночь в 2:00
0 2 * * * /opt/quoteforge/bin/priceupdater
# Генерация алертов — каждый час
0 * * * * /opt/quoteforge/bin/priceupdater --alerts-only
docker-compose up -d
```
### Manual Cron Job Execution
You can also run cron jobs manually using the quoteforge-cron binary:
```bash
# Check and generate alerts
go run ./cmd/cron -job=alerts
# Recalculate all prices
go run ./cmd/cron -job=update-prices
# Reset usage counters
go run ./cmd/cron -job=reset-counters
# Update popularity scores
go run ./cmd/cron -job=update-popularity
```
### Cron Job Details
- **Alerts check**: Generates alerts for components with high demand and stale prices, trending components without prices, and components with no recent quotes
- **Price updates**: Recalculates prices for all components using configured methods (median, weighted median, average)
- **Usage counter reset**: Resets weekly and monthly usage counters for components
- **Popularity score updates**: Recalculates popularity scores based on supplier quote activity
## Разработка
```bash
# Запуск в режиме разработки (hot reload)
make dev
go run ./cmd/server
# Запуск тестов
make test
# Линтер
make lint
go test ./...
# Сборка для Linux
make build-linux
CGO_ENABLED=0 go build -ldflags="-s -w" -o bin/quoteforge ./cmd/server
```
## Переменные окружения

View File

@@ -1,27 +0,0 @@
#!/bin/bash
# Apply migration to add custom_price column
# Usage: ./apply_migration.sh
# Load database config from config.yaml or environment
DB_HOST="${DB_HOST:-localhost}"
DB_PORT="${DB_PORT:-3306}"
DB_NAME="${DB_NAME:-RFQ_LOG}"
DB_USER="${DB_USER:-root}"
DB_PASS="${DB_PASS}"
echo "Applying migration: 002_add_custom_price.sql"
echo "Database: $DB_NAME at $DB_HOST:$DB_PORT"
if [ -z "$DB_PASS" ]; then
mysql -h "$DB_HOST" -P "$DB_PORT" -u "$DB_USER" "$DB_NAME" < migrations/002_add_custom_price.sql
else
mysql -h "$DB_HOST" -P "$DB_PORT" -u "$DB_USER" -p"$DB_PASS" "$DB_NAME" < migrations/002_add_custom_price.sql
fi
if [ $? -eq 0 ]; then
echo "Migration applied successfully!"
else
echo "Migration failed!"
exit 1
fi

85
cmd/cron/main.go Normal file
View File

@@ -0,0 +1,85 @@
package main
import (
"flag"
"log"
"time"
"git.mchus.pro/mchus/quoteforge/internal/config"
"git.mchus.pro/mchus/quoteforge/internal/models"
"git.mchus.pro/mchus/quoteforge/internal/repository"
"git.mchus.pro/mchus/quoteforge/internal/services/alerts"
"git.mchus.pro/mchus/quoteforge/internal/services/pricing"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
func main() {
configPath := flag.String("config", "config.yaml", "path to config file")
cronJob := flag.String("job", "", "type of cron job to run (alerts, update-prices)")
flag.Parse()
cfg, err := config.Load(*configPath)
if err != nil {
log.Fatalf("Failed to load config: %v", err)
}
db, err := gorm.Open(mysql.Open(cfg.Database.DSN()), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
log.Fatalf("Failed to connect to database: %v", err)
}
// Ensure tables exist
if err := models.Migrate(db); err != nil {
log.Fatalf("Migration failed: %v", err)
}
// Initialize repositories
statsRepo := repository.NewStatsRepository(db)
alertRepo := repository.NewAlertRepository(db)
componentRepo := repository.NewComponentRepository(db)
priceRepo := repository.NewPriceRepository(db)
// Initialize services
alertService := alerts.NewService(alertRepo, componentRepo, priceRepo, statsRepo, cfg.Alerts, cfg.Pricing)
pricingService := pricing.NewService(componentRepo, priceRepo, cfg.Pricing)
switch *cronJob {
case "alerts":
log.Println("Running alerts check...")
if err := alertService.CheckAndGenerateAlerts(); err != nil {
log.Printf("Error running alerts check: %v", err)
} else {
log.Println("Alerts check completed successfully")
}
case "update-prices":
log.Println("Recalculating all prices...")
updated, errors := pricingService.RecalculateAllPrices()
log.Printf("Prices recalculated: %d updated, %d errors", updated, errors)
case "reset-counters":
log.Println("Resetting usage counters...")
if err := statsRepo.ResetWeeklyCounters(); err != nil {
log.Printf("Error resetting weekly counters: %v", err)
}
if err := statsRepo.ResetMonthlyCounters(); err != nil {
log.Printf("Error resetting monthly counters: %v", err)
}
log.Println("Usage counters reset completed")
case "update-popularity":
log.Println("Updating popularity scores...")
if err := statsRepo.UpdatePopularityScores(); err != nil {
log.Printf("Error updating popularity scores: %v", err)
} else {
log.Println("Popularity scores updated successfully")
}
default:
log.Println("No valid cron job specified. Available jobs:")
log.Println(" - alerts: Check and generate alerts")
log.Println(" - update-prices: Recalculate all prices")
log.Println(" - reset-counters: Reset usage counters")
log.Println(" - update-popularity: Update popularity scores")
}
}

15
crontab Normal file
View File

@@ -0,0 +1,15 @@
# Cron jobs for QuoteForge
# Run alerts check every hour
0 * * * * /app/quoteforge-cron -job=alerts
# Run price updates daily at 2 AM
0 2 * * * /app/quoteforge-cron -job=update-prices
# Reset weekly counters every Sunday at 1 AM
0 1 * * 0 /app/quoteforge-cron -job=reset-counters
# Update popularity scores daily at 3 AM
0 3 * * * /app/quoteforge-cron -job=update-popularity
# Log rotation (optional)
# 0 0 * * * /usr/bin/logrotate /etc/logrotate.conf

30
docker-compose.yml Normal file
View File

@@ -0,0 +1,30 @@
version: '3.8'
services:
quoteforge:
build: .
container_name: quoteforge
restart: unless-stopped
ports:
- "8080:8080"
volumes:
- ./config.yaml:/app/config.yaml:ro
environment:
- TZ=Europe/Moscow
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/health"]
interval: 30s
timeout: 3s
retries: 3
start_period: 5s
quoteforge-cron:
build: .
container_name: quoteforge-cron
restart: unless-stopped
volumes:
- ./config.yaml:/app/config.yaml:ro
- ./logs:/app/logs
command: /usr/sbin/crond -f -l 8
environment:
- TZ=Europe/Moscow

View File

@@ -22,9 +22,10 @@ func (h *ComponentHandler) List(c *gin.Context) {
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "20"))
filter := repository.ComponentFilter{
Category: c.Query("category"),
Search: c.Query("search"),
HasPrice: c.Query("has_price") == "true",
Category: c.Query("category"),
Search: c.Query("search"),
HasPrice: c.Query("has_price") == "true",
ExcludeHidden: c.Query("include_hidden") != "true", // По умолчанию скрытые не показываются
}
result, err := h.componentService.List(filter, page, perPage)

View File

@@ -4,6 +4,7 @@ import (
"net/http"
"sort"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
@@ -80,7 +81,8 @@ func (h *PricingHandler) GetStats(c *gin.Context) {
type ComponentWithCount struct {
models.LotMetadata
QuoteCount int64 `json:"quote_count"`
QuoteCount int64 `json:"quote_count"`
UsedInMeta []string `json:"used_in_meta,omitempty"` // List of meta-articles that use this component
}
func (h *PricingHandler) ListComponents(c *gin.Context) {
@@ -116,12 +118,16 @@ func (h *PricingHandler) ListComponents(c *gin.Context) {
counts, _ := h.priceRepo.GetQuoteCounts(lotNames)
// Get meta usage information
metaUsage := h.getMetaUsageMap(lotNames)
// Combine components with counts
result := make([]ComponentWithCount, len(components))
for i, comp := range components {
result[i] = ComponentWithCount{
LotMetadata: comp,
QuoteCount: counts[comp.LotName],
UsedInMeta: metaUsage[comp.LotName],
}
}
@@ -133,6 +139,79 @@ func (h *PricingHandler) ListComponents(c *gin.Context) {
})
}
// getMetaUsageMap returns a map of lot_name -> list of meta-articles that use this component
func (h *PricingHandler) getMetaUsageMap(lotNames []string) map[string][]string {
result := make(map[string][]string)
// Get all components with meta_prices
var metaComponents []models.LotMetadata
h.db.Where("meta_prices IS NOT NULL AND meta_prices != ''").Find(&metaComponents)
// Build reverse lookup: which components are used in which meta-articles
for _, meta := range metaComponents {
sources := strings.Split(meta.MetaPrices, ",")
for _, source := range sources {
source = strings.TrimSpace(source)
if source == "" {
continue
}
// Handle wildcard patterns
if strings.HasSuffix(source, "*") {
prefix := strings.TrimSuffix(source, "*")
for _, lotName := range lotNames {
if strings.HasPrefix(lotName, prefix) && lotName != meta.LotName {
result[lotName] = append(result[lotName], meta.LotName)
}
}
} else {
// Direct match
for _, lotName := range lotNames {
if lotName == source && lotName != meta.LotName {
result[lotName] = append(result[lotName], meta.LotName)
}
}
}
}
}
return result
}
// expandMetaPrices expands meta_prices string to list of actual lot names
func (h *PricingHandler) expandMetaPrices(metaPrices, excludeLot string) []string {
sources := strings.Split(metaPrices, ",")
var result []string
seen := make(map[string]bool)
for _, source := range sources {
source = strings.TrimSpace(source)
if source == "" {
continue
}
if strings.HasSuffix(source, "*") {
// Wildcard pattern - find matching lots
prefix := strings.TrimSuffix(source, "*")
var matchingLots []string
h.db.Model(&models.LotMetadata{}).
Where("lot_name LIKE ? AND lot_name != ?", prefix+"%", excludeLot).
Pluck("lot_name", &matchingLots)
for _, lot := range matchingLots {
if !seen[lot] {
result = append(result, lot)
seen[lot] = true
}
}
} else if source != excludeLot && !seen[source] {
result = append(result, source)
seen[source] = true
}
}
return result
}
func (h *PricingHandler) GetComponentPricing(c *gin.Context) {
lotName := c.Param("lot_name")
@@ -161,6 +240,11 @@ type UpdatePriceRequest struct {
Coefficient float64 `json:"coefficient"`
ManualPrice *float64 `json:"manual_price"`
ClearManual bool `json:"clear_manual"`
MetaEnabled bool `json:"meta_enabled"`
MetaPrices string `json:"meta_prices"`
MetaMethod string `json:"meta_method"`
MetaPeriod int `json:"meta_period"`
IsHidden bool `json:"is_hidden"`
}
func (h *PricingHandler) UpdatePrice(c *gin.Context) {
@@ -185,6 +269,16 @@ func (h *PricingHandler) UpdatePrice(c *gin.Context) {
// Update coefficient
updates["price_coefficient"] = req.Coefficient
// Handle meta prices
if req.MetaEnabled && req.MetaPrices != "" {
updates["meta_prices"] = req.MetaPrices
} else {
updates["meta_prices"] = ""
}
// Handle hidden flag
updates["is_hidden"] = req.IsHidden
// Handle manual price
if req.ClearManual {
updates["manual_price"] = nil
@@ -236,18 +330,47 @@ func (h *PricingHandler) recalculateSinglePrice(lotName string) {
method = models.PriceMethodMedian
}
// Get prices based on period
var prices []float64
if periodDays > 0 {
h.db.Raw(`SELECT price FROM lot_log WHERE lot = ? AND date >= DATE_SUB(NOW(), INTERVAL ? DAY) ORDER BY price`,
lotName, periodDays).Pluck("price", &prices)
// Determine which lot names to use for price calculation
lotNames := []string{lotName}
if comp.MetaPrices != "" {
lotNames = h.expandMetaPrices(comp.MetaPrices, lotName)
}
// If no prices in period, try all time
if len(prices) == 0 {
h.db.Raw(`SELECT price FROM lot_log WHERE lot = ? ORDER BY price`, lotName).Pluck("price", &prices)
// Get prices based on period from all relevant lots
var prices []float64
for _, ln := range lotNames {
var lotPrices []float64
if strings.HasSuffix(ln, "*") {
pattern := strings.TrimSuffix(ln, "*") + "%"
if periodDays > 0 {
h.db.Raw(`SELECT price FROM lot_log WHERE lot LIKE ? AND date >= DATE_SUB(NOW(), INTERVAL ? DAY) ORDER BY price`,
pattern, periodDays).Pluck("price", &lotPrices)
} else {
h.db.Raw(`SELECT price FROM lot_log WHERE lot LIKE ? ORDER BY price`, pattern).Pluck("price", &lotPrices)
}
} else {
if periodDays > 0 {
h.db.Raw(`SELECT price FROM lot_log WHERE lot = ? AND date >= DATE_SUB(NOW(), INTERVAL ? DAY) ORDER BY price`,
ln, periodDays).Pluck("price", &lotPrices)
} else {
h.db.Raw(`SELECT price FROM lot_log WHERE lot = ? ORDER BY price`, ln).Pluck("price", &lotPrices)
}
}
prices = append(prices, lotPrices...)
}
// If no prices in period, try all time
if len(prices) == 0 && periodDays > 0 {
for _, ln := range lotNames {
var lotPrices []float64
if strings.HasSuffix(ln, "*") {
pattern := strings.TrimSuffix(ln, "*") + "%"
h.db.Raw(`SELECT price FROM lot_log WHERE lot LIKE ? ORDER BY price`, pattern).Pluck("price", &lotPrices)
} else {
h.db.Raw(`SELECT price FROM lot_log WHERE lot = ? ORDER BY price`, ln).Pluck("price", &lotPrices)
}
prices = append(prices, lotPrices...)
}
} else {
h.db.Raw(`SELECT price FROM lot_log WHERE lot = ? ORDER BY price`, lotName).Pluck("price", &prices)
}
if len(prices) == 0 {
@@ -255,6 +378,7 @@ func (h *PricingHandler) recalculateSinglePrice(lotName string) {
}
// Calculate price based on method
sortFloat64s(prices)
var finalPrice float64
switch method {
case models.PriceMethodMedian:
@@ -295,61 +419,95 @@ func (h *PricingHandler) RecalculateAll(c *gin.Context) {
h.db.Find(&components)
total := int64(len(components))
// Pre-load all lot names for efficient wildcard matching
var allLotNames []string
h.db.Model(&models.LotMetadata{}).Pluck("lot_name", &allLotNames)
lotNameSet := make(map[string]bool, len(allLotNames))
for _, ln := range allLotNames {
lotNameSet[ln] = true
}
// Pre-load latest quote dates for all lots (for checking updates)
type LotDate struct {
Lot string
Date time.Time
}
var latestDates []LotDate
h.db.Raw(`SELECT lot, MAX(date) as date FROM lot_log GROUP BY lot`).Scan(&latestDates)
lotLatestDate := make(map[string]time.Time, len(latestDates))
for _, ld := range latestDates {
lotLatestDate[ld.Lot] = ld.Date
}
// Send initial progress
c.SSEvent("progress", gin.H{"current": 0, "total": total, "status": "starting"})
c.Writer.Flush()
c.SSEvent("progress", gin.H{"current": 0, "total": total, "status": "processing", "updated": 0, "skipped": 0, "manual": 0, "errors": 0})
c.Writer.Flush()
// Process components individually to respect their settings
var updated, skipped, manual, errors int
var updated, skipped, manual, unchanged, errors int
now := time.Now()
progressCounter := 0
for i, comp := range components {
// If manual price is set, use it
for _, comp := range components {
progressCounter++
// If manual price is set, skip recalculation
if comp.ManualPrice != nil && *comp.ManualPrice > 0 {
err := h.db.Model(&models.LotMetadata{}).
Where("lot_name = ?", comp.LotName).
Updates(map[string]interface{}{
"current_price": *comp.ManualPrice,
"price_updated_at": now,
}).Error
if err != nil {
errors++
} else {
manual++
}
manual++
goto sendProgress
}
// Calculate price based on component's individual settings
{
var basePrice *float64
periodDays := comp.PricePeriodDays
method := comp.PriceMethod
if method == "" {
method = models.PriceMethodMedian
}
// Build query based on period
var query string
var args []interface{}
if periodDays > 0 {
query = `SELECT price FROM lot_log WHERE lot = ? AND date >= DATE_SUB(NOW(), INTERVAL ? DAY) ORDER BY price`
args = []interface{}{comp.LotName, periodDays}
// Determine source lots for price calculation (using cached lot names)
var sourceLots []string
if comp.MetaPrices != "" {
sourceLots = expandMetaPricesWithCache(comp.MetaPrices, comp.LotName, allLotNames)
} else {
query = `SELECT price FROM lot_log WHERE lot = ? ORDER BY price`
args = []interface{}{comp.LotName}
sourceLots = []string{comp.LotName}
}
if len(sourceLots) == 0 {
skipped++
goto sendProgress
}
// Check if there are new quotes since last update (using cached dates)
if comp.PriceUpdatedAt != nil {
hasNewData := false
for _, lot := range sourceLots {
if latestDate, ok := lotLatestDate[lot]; ok {
if latestDate.After(*comp.PriceUpdatedAt) {
hasNewData = true
break
}
}
}
if !hasNewData {
unchanged++
goto sendProgress
}
}
// Get prices from source lots
var prices []float64
h.db.Raw(query, args...).Pluck("price", &prices)
if periodDays > 0 {
h.db.Raw(`SELECT price FROM lot_log WHERE lot IN ? AND date >= DATE_SUB(NOW(), INTERVAL ? DAY) ORDER BY price`,
sourceLots, periodDays).Pluck("price", &prices)
} else {
h.db.Raw(`SELECT price FROM lot_log WHERE lot IN ? ORDER BY price`,
sourceLots).Pluck("price", &prices)
}
// If no prices in period, try all time
if len(prices) == 0 && periodDays > 0 {
h.db.Raw(`SELECT price FROM lot_log WHERE lot = ? ORDER BY price`, comp.LotName).Pluck("price", &prices)
h.db.Raw(`SELECT price FROM lot_log WHERE lot IN ? ORDER BY price`, sourceLots).Pluck("price", &prices)
}
if len(prices) == 0 {
@@ -358,24 +516,22 @@ func (h *PricingHandler) RecalculateAll(c *gin.Context) {
}
// Calculate price based on method
var basePrice float64
switch method {
case models.PriceMethodMedian:
median := calculateMedian(prices)
basePrice = &median
basePrice = calculateMedian(prices)
case models.PriceMethodAverage:
avg := calculateAverage(prices)
basePrice = &avg
basePrice = calculateAverage(prices)
default:
median := calculateMedian(prices)
basePrice = &median
basePrice = calculateMedian(prices)
}
if basePrice == nil || *basePrice <= 0 {
if basePrice <= 0 {
skipped++
goto sendProgress
}
finalPrice := *basePrice
finalPrice := basePrice
// Apply coefficient
if comp.PriceCoefficient != 0 {
@@ -397,16 +553,18 @@ func (h *PricingHandler) RecalculateAll(c *gin.Context) {
}
sendProgress:
// Send progress update every 50 components
if (i+1)%50 == 0 || i == len(components)-1 {
// Send progress update every 10 components to reduce overhead
if progressCounter%10 == 0 || progressCounter == int(total) {
c.SSEvent("progress", gin.H{
"current": updated + skipped + manual + errors,
"total": total,
"updated": updated,
"skipped": skipped,
"manual": manual,
"errors": errors,
"status": "processing",
"current": updated + skipped + manual + unchanged + errors,
"total": total,
"updated": updated,
"skipped": skipped,
"manual": manual,
"unchanged": unchanged,
"errors": errors,
"status": "processing",
"lot_name": comp.LotName,
})
c.Writer.Flush()
}
@@ -417,13 +575,14 @@ func (h *PricingHandler) RecalculateAll(c *gin.Context) {
// Send completion
c.SSEvent("progress", gin.H{
"current": updated + skipped + manual + errors,
"total": total,
"updated": updated,
"skipped": skipped,
"manual": manual,
"errors": errors,
"status": "completed",
"current": updated + skipped + manual + unchanged + errors,
"total": total,
"updated": updated,
"skipped": skipped,
"manual": manual,
"unchanged": unchanged,
"errors": errors,
"status": "completed",
})
c.Writer.Flush()
}
@@ -503,6 +662,8 @@ type PreviewPriceRequest struct {
Method string `json:"method"`
PeriodDays int `json:"period_days"`
Coefficient float64 `json:"coefficient"`
MetaEnabled bool `json:"meta_enabled"`
MetaPrices string `json:"meta_prices"`
}
func (h *PricingHandler) PreviewPrice(c *gin.Context) {
@@ -519,22 +680,48 @@ func (h *PricingHandler) PreviewPrice(c *gin.Context) {
return
}
// Get all prices for calculations
// Determine which lot names to use for price calculation
lotNames := []string{req.LotName}
if req.MetaEnabled && req.MetaPrices != "" {
lotNames = h.expandMetaPrices(req.MetaPrices, req.LotName)
}
// Get all prices for calculations (from all relevant lots)
var allPrices []float64
h.db.Raw(`SELECT price FROM lot_log WHERE lot = ? ORDER BY price`, req.LotName).Pluck("price", &allPrices)
for _, lotName := range lotNames {
var lotPrices []float64
if strings.HasSuffix(lotName, "*") {
// Wildcard pattern
pattern := strings.TrimSuffix(lotName, "*") + "%"
h.db.Raw(`SELECT price FROM lot_log WHERE lot LIKE ? ORDER BY price`, pattern).Pluck("price", &lotPrices)
} else {
h.db.Raw(`SELECT price FROM lot_log WHERE lot = ? ORDER BY price`, lotName).Pluck("price", &lotPrices)
}
allPrices = append(allPrices, lotPrices...)
}
// Calculate median for all time
var medianAllTime *float64
if len(allPrices) > 0 {
sortFloat64s(allPrices)
median := calculateMedian(allPrices)
medianAllTime = &median
}
// Get quote count
// Get quote count (from all relevant lots)
var quoteCount int64
h.db.Model(&models.LotLog{}).Where("lot = ?", req.LotName).Count(&quoteCount)
for _, lotName := range lotNames {
var count int64
if strings.HasSuffix(lotName, "*") {
pattern := strings.TrimSuffix(lotName, "*") + "%"
h.db.Model(&models.LotLog{}).Where("lot LIKE ?", pattern).Count(&count)
} else {
h.db.Model(&models.LotLog{}).Where("lot = ?", lotName).Count(&count)
}
quoteCount += count
}
// Get last received price
// Get last received price (from the main lot only)
var lastPrice struct {
Price *float64
Date *time.Time
@@ -549,8 +736,18 @@ func (h *PricingHandler) PreviewPrice(c *gin.Context) {
var prices []float64
if req.PeriodDays > 0 {
h.db.Raw(`SELECT price FROM lot_log WHERE lot = ? AND date >= DATE_SUB(NOW(), INTERVAL ? DAY) ORDER BY price`,
req.LotName, req.PeriodDays).Pluck("price", &prices)
for _, lotName := range lotNames {
var lotPrices []float64
if strings.HasSuffix(lotName, "*") {
pattern := strings.TrimSuffix(lotName, "*") + "%"
h.db.Raw(`SELECT price FROM lot_log WHERE lot LIKE ? AND date >= DATE_SUB(NOW(), INTERVAL ? DAY) ORDER BY price`,
pattern, req.PeriodDays).Pluck("price", &lotPrices)
} else {
h.db.Raw(`SELECT price FROM lot_log WHERE lot = ? AND date >= DATE_SUB(NOW(), INTERVAL ? DAY) ORDER BY price`,
lotName, req.PeriodDays).Pluck("price", &lotPrices)
}
prices = append(prices, lotPrices...)
}
// Fall back to all time if no prices in period
if len(prices) == 0 {
prices = allPrices
@@ -561,6 +758,7 @@ func (h *PricingHandler) PreviewPrice(c *gin.Context) {
var newPrice *float64
if len(prices) > 0 {
sortFloat64s(prices)
var basePrice float64
if method == "average" {
basePrice = calculateAverage(prices)
@@ -585,3 +783,38 @@ func (h *PricingHandler) PreviewPrice(c *gin.Context) {
"last_price_date": lastPrice.Date,
})
}
// sortFloat64s sorts a slice of float64 in ascending order
func sortFloat64s(data []float64) {
sort.Float64s(data)
}
// expandMetaPricesWithCache expands meta_prices using pre-loaded lot names (no DB queries)
func expandMetaPricesWithCache(metaPrices, excludeLot string, allLotNames []string) []string {
sources := strings.Split(metaPrices, ",")
var result []string
seen := make(map[string]bool)
for _, source := range sources {
source = strings.TrimSpace(source)
if source == "" || source == excludeLot {
continue
}
if strings.HasSuffix(source, "*") {
// Wildcard pattern - find matching lots from cache
prefix := strings.TrimSuffix(source, "*")
for _, lot := range allLotNames {
if strings.HasPrefix(lot, prefix) && lot != excludeLot && !seen[lot] {
result = append(result, lot)
seen[lot] = true
}
}
} else if !seen[source] {
result = append(result, source)
seen[source] = true
}
}
return result
}

View File

@@ -40,16 +40,17 @@ func (c ConfigItems) Total() float64 {
}
type Configuration struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
UUID string `gorm:"size:36;uniqueIndex;not null" json:"uuid"`
UserID uint `gorm:"not null" json:"user_id"`
Name string `gorm:"size:200;not null" json:"name"`
Items ConfigItems `gorm:"type:json;not null" json:"items"`
TotalPrice *float64 `gorm:"type:decimal(12,2)" json:"total_price"`
CustomPrice *float64 `gorm:"type:decimal(12,2)" json:"custom_price"`
Notes string `gorm:"type:text" json:"notes"`
IsTemplate bool `gorm:"default:false" json:"is_template"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
UUID string `gorm:"size:36;uniqueIndex;not null" json:"uuid"`
UserID uint `gorm:"not null" json:"user_id"`
Name string `gorm:"size:200;not null" json:"name"`
Items ConfigItems `gorm:"type:json;not null" json:"items"`
TotalPrice *float64 `gorm:"type:decimal(12,2)" json:"total_price"`
CustomPrice *float64 `gorm:"type:decimal(12,2)" json:"custom_price"`
Notes string `gorm:"type:text" json:"notes"`
IsTemplate bool `gorm:"default:false" json:"is_template"`
ServerCount int `gorm:"default:1" json:"server_count"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
User *User `gorm:"foreignKey:UserID" json:"user,omitempty"`
}

View File

@@ -48,6 +48,10 @@ type LotMetadata struct {
RequestCount int `gorm:"default:0" json:"request_count"`
LastRequestDate *time.Time `gorm:"type:date" json:"last_request_date"`
PopularityScore float64 `gorm:"type:decimal(10,4);default:0" json:"popularity_score"`
MetaPrices string `gorm:"size:1000" json:"meta_prices"`
MetaMethod string `gorm:"size:20" json:"meta_method"`
MetaPeriodDays int `gorm:"default:90" json:"meta_period_days"`
IsHidden bool `gorm:"default:false" json:"is_hidden"`
// Relations
Lot *Lot `gorm:"foreignKey:LotName;references:LotName" json:"lot,omitempty"`

View File

@@ -16,11 +16,12 @@ func NewComponentRepository(db *gorm.DB) *ComponentRepository {
}
type ComponentFilter struct {
Category string
Search string
HasPrice bool
SortField string
SortDir string
Category string
Search string
HasPrice bool
ExcludeHidden bool
SortField string
SortDir string
}
func (r *ComponentRepository) List(filter ComponentFilter, offset, limit int) ([]models.LotMetadata, int64, error) {
@@ -42,6 +43,9 @@ func (r *ComponentRepository) List(filter ComponentFilter, offset, limit int) ([
if filter.HasPrice {
query = query.Where("current_price IS NOT NULL AND current_price > 0")
}
if filter.ExcludeHidden {
query = query.Where("is_hidden = ? OR is_hidden IS NULL", false)
}
query.Count(&total)

View File

@@ -37,11 +37,17 @@ type CreateConfigRequest struct {
CustomPrice *float64 `json:"custom_price"`
Notes string `json:"notes"`
IsTemplate bool `json:"is_template"`
ServerCount int `json:"server_count"`
}
func (s *ConfigurationService) Create(userID uint, req *CreateConfigRequest) (*models.Configuration, error) {
total := req.Items.Total()
// If server count is greater than 1, multiply the total by server count
if req.ServerCount > 1 {
total *= float64(req.ServerCount)
}
config := &models.Configuration{
UUID: uuid.New().String(),
UserID: userID,
@@ -51,6 +57,7 @@ func (s *ConfigurationService) Create(userID uint, req *CreateConfigRequest) (*m
CustomPrice: req.CustomPrice,
Notes: req.Notes,
IsTemplate: req.IsTemplate,
ServerCount: req.ServerCount,
}
if err := s.configRepo.Create(config); err != nil {
@@ -89,12 +96,18 @@ func (s *ConfigurationService) Update(uuid string, userID uint, req *CreateConfi
total := req.Items.Total()
// If server count is greater than 1, multiply the total by server count
if req.ServerCount > 1 {
total *= float64(req.ServerCount)
}
config.Name = req.Name
config.Items = req.Items
config.TotalPrice = &total
config.CustomPrice = req.CustomPrice
config.Notes = req.Notes
config.IsTemplate = req.IsTemplate
config.ServerCount = req.ServerCount
if err := s.configRepo.Update(config); err != nil {
return nil, err
@@ -144,6 +157,11 @@ func (s *ConfigurationService) Clone(configUUID string, userID uint, newName str
// Create copy with new UUID and name
total := original.Items.Total()
// If server count is greater than 1, multiply the total by server count
if original.ServerCount > 1 {
total *= float64(original.ServerCount)
}
clone := &models.Configuration{
UUID: uuid.New().String(),
UserID: userID,
@@ -153,6 +171,7 @@ func (s *ConfigurationService) Clone(configUUID string, userID uint, newName str
CustomPrice: original.CustomPrice,
Notes: original.Notes,
IsTemplate: false, // Clone is never a template
ServerCount: original.ServerCount,
}
if err := s.configRepo.Create(clone); err != nil {

View File

@@ -0,0 +1,2 @@
-- Add is_hidden column to qt_lot_metadata table
ALTER TABLE qt_lot_metadata ADD COLUMN is_hidden BOOLEAN DEFAULT FALSE COMMENT 'Hide component from configurator';

View File

@@ -9,6 +9,7 @@
<div class="flex gap-4">
<button onclick="loadTab('alerts')" id="btn-alerts" class="text-blue-600 font-medium">Алерты</button>
<button onclick="loadTab('components')" id="btn-components" class="text-gray-600">Компоненты</button>
<button onclick="loadTab('all-configs')" id="btn-all-configs" class="text-gray-600 hidden">Все конфигурации</button>
</div>
<button onclick="recalculateAll()" id="btn-recalc" class="px-3 py-1 bg-green-600 text-white text-sm rounded hover:bg-green-700">
Пересчитать цены
@@ -76,13 +77,29 @@
<input type="text" id="modal-lot-name" readonly class="w-full px-3 py-2 border rounded bg-gray-100">
</div>
<div class="flex items-center mb-2">
<input type="checkbox" id="modal-meta-enabled" class="mr-2" onchange="toggleMetaPrice()">
<span class="text-sm font-medium text-gray-700">Мета артикул</span>
</div>
<div id="meta-price-fields" class="hidden mt-2">
<label class="block text-sm font-medium text-gray-700 mb-1">Источники цен</label>
<input type="text" id="modal-meta-prices" class="w-full px-3 py-2 border rounded" placeholder="Артикулы через запятую (например: CPU_AMD_9654, MB_INTEL_4.Sapphire_2S)">
<p class="text-xs text-gray-500 mt-1">Артикулы, чьи цены будут использоваться в расчете. Для автоматического подбора используйте * в конце (например: CPU_AMD_9654*)</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Метод расчёта</label>
<select id="modal-method" class="w-full px-3 py-2 border rounded">
<select id="modal-method" class="w-full px-3 py-2 border rounded" onchange="onMethodChange()">
<option value="median">Медиана</option>
<option value="average">Среднее</option>
<option value="manual">Установить цену вручную</option>
</select>
</div>
<div id="manual-price-field" class="hidden">
<label class="block text-sm font-medium text-gray-700 mb-1">Ручная цена (USD)</label>
<input type="number" id="modal-manual-price" step="0.01" class="w-full px-3 py-2 border rounded" placeholder="Цена USD">
<p class="text-xs text-gray-500 mt-1">Ручная цена сохраняется при пересчёте</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Период расчёта</label>
@@ -101,13 +118,9 @@
<p class="text-xs text-gray-500 mt-1">Например: 30 для +30%, -10 для -10%</p>
</div>
<div class="border-t pt-4">
<label class="flex items-center mb-2">
<input type="checkbox" id="modal-manual-enabled" class="mr-2" onchange="toggleManualPrice()">
<span class="text-sm font-medium text-gray-700">Установить цену вручную</span>
</label>
<input type="number" id="modal-manual-price" step="0.01" class="w-full px-3 py-2 border rounded" placeholder="Цена USD" disabled>
<p class="text-xs text-gray-500 mt-1">Ручная цена сохраняется при пересчёте</p>
<div class="flex items-center pt-2 border-t">
<input type="checkbox" id="modal-hidden" class="mr-2">
<span class="text-sm font-medium text-gray-700">Скрыть из конфигуратора</span>
</div>
<div class="bg-gray-50 p-3 rounded space-y-2">
@@ -153,8 +166,22 @@ async function loadTab(tab) {
document.getElementById('btn-alerts').className = tab === 'alerts' ? 'text-blue-600 font-medium' : 'text-gray-600';
document.getElementById('btn-components').className = tab === 'components' ? 'text-blue-600 font-medium' : 'text-gray-600';
document.getElementById('search-bar').className = tab === 'components' ? 'mb-4' : 'mb-4 hidden';
document.getElementById('pagination').className = tab === 'components' ? 'flex justify-between items-center mt-4 pt-4 border-t' : 'hidden';
document.getElementById('btn-all-configs').className = tab === 'all-configs' ? 'text-blue-600 font-medium' : 'text-gray-600 hidden';
// Show/hide elements based on tab
if (tab === 'components') {
document.getElementById('search-bar').className = 'mb-4';
document.getElementById('pagination').className = 'flex justify-between items-center mt-4 pt-4 border-t';
document.getElementById('btn-all-configs').className = 'text-gray-600 hidden'; // Hide this tab for components
} else if (tab === 'all-configs') {
document.getElementById('search-bar').className = 'mb-4 hidden'; // Hide search for all configs
document.getElementById('pagination').className = 'flex justify-between items-center mt-4 pt-4 border-t'; // Show pagination
document.getElementById('btn-all-configs').className = 'text-blue-600 font-medium'; // Show this tab for all configs
} else {
document.getElementById('search-bar').className = 'mb-4 hidden';
document.getElementById('pagination').className = 'hidden';
document.getElementById('btn-all-configs').className = 'text-gray-600 hidden';
}
await loadData();
}
@@ -177,6 +204,21 @@ async function loadData() {
if (resp.status === 403) { window.location.href = '/'; return; }
const data = await resp.json();
renderAlerts(data.alerts || []);
} else if (currentTab === 'all-configs') {
// Load all configurations for all users
let url = '/api/configs?page=' + currentPage + '&per_page=' + perPage;
if (currentSearch) {
url += '&search=' + encodeURIComponent(currentSearch);
}
const resp = await fetch(url, {
headers: {'Authorization': 'Bearer ' + token}
});
if (resp.status === 401) { logout(); return; }
if (resp.status === 403) { window.location.href = '/'; return; }
const data = await resp.json();
totalPages = Math.ceil(data.total / perPage);
renderAllConfigs(data.configurations || []);
updatePagination(data.total);
} else {
let url = '/admin/pricing/components?page=' + currentPage + '&per_page=' + perPage;
if (currentSearch) {
@@ -273,33 +315,77 @@ function renderComponents(components, total) {
const desc = c.lot && c.lot.lot_description ? c.lot.lot_description : '—';
const quoteCount = c.quote_count || 0;
const popularity = c.popularity_score ? c.popularity_score.toFixed(2) : '0.00';
const isHidden = c.is_hidden || quoteCount === 0;
const usedInMeta = c.used_in_meta && c.used_in_meta.length > 0;
// Determine status indicator (colored dot)
let dotColor, dotTitle;
if (usedInMeta) {
// Used as source for meta-articles - cyan
dotColor = 'bg-cyan-500';
dotTitle = 'Используется в мета: ' + c.used_in_meta.join(', ');
} else if (!isHidden) {
// Available in configurator - green
dotColor = 'bg-green-500';
dotTitle = 'Доступен в конфигураторе';
} else {
// Hidden and not used - gray
dotColor = 'bg-gray-400';
dotTitle = 'Скрыт из конфигуратора';
}
// Build settings summary
let settings = [];
const method = c.price_method || 'median';
settings.push(method === 'median' ? 'М' : 'С');
const period = c.price_period_days !== undefined && c.price_period_days !== null ? c.price_period_days : 90;
if (period === 7) settings.push('1н');
else if (period === 30) settings.push('1м');
else if (period === 90) settings.push('3м');
else if (period === 365) settings.push('1г');
else if (period === 0) settings.push('все');
else settings.push(period + 'д');
if (c.price_coefficient && c.price_coefficient !== 0) {
settings.push((c.price_coefficient > 0 ? '+' : '') + c.price_coefficient + '%');
}
if (c.manual_price && c.manual_price > 0) {
settings.push('РУЧН');
let settingsHtml = '';
if (isHidden) {
settingsHtml = '<span class="text-red-600 font-medium">СКРЫТ</span>';
} else {
let settings = [];
const method = c.price_method || 'median';
const hasManualPrice = c.manual_price && c.manual_price > 0;
const hasMeta = c.meta_prices && c.meta_prices.trim() !== '';
// Method indicator
if (hasManualPrice) {
settings.push('<span class="text-orange-600 font-medium">РУЧН</span>');
} else if (method === 'average') {
settings.push('Сред');
} else {
settings.push('Мед');
}
// Period (only if not manual price)
if (!hasManualPrice) {
const period = c.price_period_days !== undefined && c.price_period_days !== null ? c.price_period_days : 90;
if (period === 7) settings.push('1н');
else if (period === 30) settings.push('1м');
else if (period === 90) settings.push('3м');
else if (period === 365) settings.push('1г');
else if (period === 0) settings.push('все');
else settings.push(period + 'д');
}
// Coefficient
if (c.price_coefficient && c.price_coefficient !== 0) {
settings.push((c.price_coefficient > 0 ? '+' : '') + c.price_coefficient + '%');
}
// Meta article indicator
if (hasMeta) {
settings.push('<span class="text-purple-600 font-medium">МЕТА</span>');
}
settingsHtml = settings.join(' | ');
}
html += '<tr class="hover:bg-gray-50 cursor-pointer" onclick="openModal(' + idx + ')">';
html += '<td class="px-3 py-2 text-sm font-mono">' + escapeHtml(c.lot_name) + '</td>';
html += '<td class="px-3 py-2 text-sm font-mono"><span class="inline-flex items-center gap-2"><span class="w-2.5 h-2.5 rounded-full ' + dotColor + ' flex-shrink-0" title="' + escapeHtml(dotTitle) + '"></span>' + escapeHtml(c.lot_name) + '</span></td>';
html += '<td class="px-3 py-2 text-sm">' + escapeHtml(category) + '</td>';
html += '<td class="px-3 py-2 text-sm text-gray-500 max-w-xs truncate">' + escapeHtml(desc) + '</td>';
html += '<td class="px-3 py-2 text-sm text-right">' + popularity + '</td>';
html += '<td class="px-3 py-2 text-sm text-right">' + quoteCount + '</td>';
html += '<td class="px-3 py-2 text-sm text-right font-medium">' + price + '</td>';
html += '<td class="px-3 py-2 text-sm"><span class="text-xs bg-gray-100 px-2 py-1 rounded">' + settings.join(' | ') + '</span></td>';
html += '<td class="px-3 py-2 text-sm"><span class="text-xs bg-gray-100 px-2 py-1 rounded">' + settingsHtml + '</span></td>';
html += '</tr>';
});
@@ -320,14 +406,41 @@ function openModal(idx) {
if (!c) return;
document.getElementById('modal-lot-name').value = c.lot_name;
document.getElementById('modal-method').value = c.price_method || 'median';
document.getElementById('modal-period').value = String(c.price_period_days !== undefined && c.price_period_days !== null ? c.price_period_days : 90);
document.getElementById('modal-coefficient').value = c.price_coefficient || 0;
const hasManual = c.manual_price && c.manual_price > 0;
document.getElementById('modal-manual-enabled').checked = hasManual;
document.getElementById('modal-manual-price').value = hasManual ? c.manual_price : '';
document.getElementById('modal-manual-price').disabled = !hasManual;
if (hasManual) {
document.getElementById('modal-method').value = 'manual';
document.getElementById('modal-manual-price').value = c.manual_price;
document.getElementById('manual-price-field').classList.remove('hidden');
} else {
document.getElementById('modal-method').value = c.price_method || 'median';
document.getElementById('modal-manual-price').value = '';
document.getElementById('manual-price-field').classList.add('hidden');
}
document.getElementById('modal-period').value = String(c.price_period_days !== undefined && c.price_period_days !== null ? c.price_period_days : 90);
// Load meta prices settings
const hasMeta = c.meta_prices && c.meta_prices.trim() !== '';
document.getElementById('modal-meta-enabled').checked = hasMeta;
document.getElementById('modal-meta-prices').value = c.meta_prices || '';
if (hasMeta) {
document.getElementById('meta-price-fields').classList.remove('hidden');
} else {
document.getElementById('meta-price-fields').classList.add('hidden');
}
// Load hidden flag
const quoteCount = c.quote_count || 0;
const hiddenCheckbox = document.getElementById('modal-hidden');
if (quoteCount === 0) {
// Если нет котировок - чекбокс установлен и заблокирован
hiddenCheckbox.checked = true;
hiddenCheckbox.disabled = true;
} else {
hiddenCheckbox.checked = c.is_hidden || false;
hiddenCheckbox.disabled = false;
}
// Reset price displays while loading
document.getElementById('modal-last-price').textContent = '...';
@@ -343,6 +456,20 @@ function openModal(idx) {
fetchPreview();
}
function onMethodChange() {
const method = document.getElementById('modal-method').value;
const manualField = document.getElementById('manual-price-field');
if (method === 'manual') {
manualField.classList.remove('hidden');
// При выборе "Установить цену вручную" снимаем галочку "Мета артикул"
document.getElementById('modal-meta-enabled').checked = false;
document.getElementById('meta-price-fields').classList.add('hidden');
} else {
manualField.classList.add('hidden');
}
fetchPreview();
}
async function fetchPreview() {
const token = localStorage.getItem('token');
if (!token) return;
@@ -351,6 +478,16 @@ async function fetchPreview() {
const method = document.getElementById('modal-method').value;
const periodDays = parseInt(document.getElementById('modal-period').value) || 0;
const coefficient = parseFloat(document.getElementById('modal-coefficient').value) || 0;
const metaEnabled = document.getElementById('modal-meta-enabled').checked;
let metaPrices = '';
let metaMethod = '';
let metaPeriod = 0;
if (metaEnabled) {
metaPrices = document.getElementById('modal-meta-prices').value.trim();
metaMethod = method;
metaPeriod = periodDays;
}
try {
const resp = await fetch('/admin/pricing/preview', {
@@ -363,7 +500,11 @@ async function fetchPreview() {
lot_name: lotName,
method: method,
period_days: periodDays,
coefficient: coefficient
coefficient: coefficient,
meta_enabled: metaEnabled,
meta_prices: metaPrices,
meta_method: metaMethod,
meta_period: metaPeriod
})
});
@@ -413,11 +554,24 @@ function closeModal() {
document.getElementById('price-modal').classList.remove('flex');
}
function toggleManualPrice() {
const enabled = document.getElementById('modal-manual-enabled').checked;
document.getElementById('modal-manual-price').disabled = !enabled;
if (!enabled) {
document.getElementById('modal-manual-price').value = '';
function toggleMetaPrice() {
const enabled = document.getElementById('modal-meta-enabled').checked;
const fields = document.getElementById('meta-price-fields');
fields.classList.toggle('hidden', !enabled);
if (enabled) {
// When enabling meta price, reset method to median if it was manual
const method = document.getElementById('modal-method').value;
if (method === 'manual') {
document.getElementById('modal-method').value = 'median';
document.getElementById('manual-price-field').classList.add('hidden');
document.getElementById('modal-manual-price').value = '';
}
// Auto-fill with wildcard pattern
const lotName = document.getElementById('modal-lot-name').value;
if (lotName) {
autoFillMetaPrices(lotName);
}
}
fetchPreview();
}
@@ -441,15 +595,34 @@ async function savePrice() {
const periodDaysStr = document.getElementById('modal-period').value;
const periodDays = periodDaysStr !== '' ? parseInt(periodDaysStr) : 90;
const coefficient = parseFloat(document.getElementById('modal-coefficient').value) || 0;
const manualEnabled = document.getElementById('modal-manual-enabled').checked;
const manualEnabled = method === 'manual';
const manualPrice = manualEnabled ? parseFloat(document.getElementById('modal-manual-price').value) : null;
const metaEnabled = document.getElementById('modal-meta-enabled').checked;
const hiddenCheckbox = document.getElementById('modal-hidden');
// Если чекбокс заблокирован (нет котировок), всегда true
const isHidden = hiddenCheckbox.disabled ? true : hiddenCheckbox.checked;
let metaPrices = '';
let metaMethod = '';
let metaPeriod = 0;
if (metaEnabled) {
metaPrices = document.getElementById('modal-meta-prices').value.trim();
metaMethod = manualEnabled ? 'median' : method;
metaPeriod = periodDays;
}
const body = {
lot_name: lotName,
method: method,
method: manualEnabled ? 'median' : method,
period_days: periodDays,
coefficient: coefficient,
clear_manual: !manualEnabled
clear_manual: !manualEnabled,
meta_enabled: metaEnabled,
meta_prices: metaPrices,
meta_method: metaMethod,
meta_period: metaPeriod,
is_hidden: isHidden
};
if (manualEnabled && manualPrice > 0) {
@@ -480,6 +653,35 @@ async function savePrice() {
}
}
// Function to process meta prices and handle regex patterns
function processMetaPrices(metaPrices, originalLotName) {
if (!metaPrices) return [];
// Split by comma and clean up
let lots = metaPrices.split(',').map(lot => lot.trim()).filter(lot => lot.length > 0);
// Handle wildcard patterns (ending with *)
const processedLots = [];
const originalPrefix = originalLotName.split('_')[0] + '_'; // Get first part like "CPU_" from "CPU_AMD_9654"
lots.forEach(lot => {
if (lot.endsWith('*')) {
// Wildcard pattern - find all components that start with the prefix
const prefix = lot.slice(0, -1); // Remove the *
// In real implementation, this would be handled by backend
// For now, we'll just add the prefix as is to indicate it's a pattern
processedLots.push(prefix + '*');
} else {
// Regular component name
processedLots.push(lot);
}
});
// Remove duplicates and original lot name
const uniqueLots = [...new Set(processedLots)];
return uniqueLots.filter(lot => lot !== originalLotName);
}
function recalculateAll() {
const token = localStorage.getItem('token');
if (!token) {
@@ -544,10 +746,10 @@ function recalculateAll() {
progressText.textContent = 'Пересчёт завершён!';
progressBar.className = 'bg-green-600 h-4 rounded-full';
} else {
progressText.textContent = 'Обработка компонентов...';
progressText.textContent = data.lot_name ? 'Обработка: ' + data.lot_name : 'Обработка компонентов...';
}
progressStats.textContent = 'Обновлено: ' + (data.updated || 0) + ' | Ручные: ' + (data.manual || 0) + ' | Нет данных: ' + (data.skipped || 0) + ' | Ошибок: ' + (data.errors || 0);
progressStats.textContent = 'Обновлено: ' + (data.updated || 0) + ' | Без изменений: ' + (data.unchanged || 0) + ' | Ручные: ' + (data.manual || 0) + ' | Нет данных: ' + (data.skipped || 0) + ' | Ошибок: ' + (data.errors || 0);
} catch(e) {
console.log('Parse error:', e, line);
}
@@ -588,13 +790,65 @@ function toggleSortDir() {
loadData();
}
// Render all configurations for admin view
function renderAllConfigs(configs) {
if (configs.length === 0) {
document.getElementById('tab-content').innerHTML = '<div class="text-center py-8 text-gray-500">Нет конфигураций</div>';
return;
}
let html = '<div class="overflow-x-auto"><table class="w-full"><thead class="bg-gray-50"><tr>';
html += '<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Дата</th>';
html += '<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Название</th>';
html += '<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Пользователь</th>';
html += '<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Серверы</th>';
html += '<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase">Сумма</th>';
html += '<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase">Действия</th>';
html += '</tr></thead><tbody class="divide-y">';
configs.forEach(c => {
const date = new Date(c.created_at).toLocaleDateString('ru-RU');
const total = c.total_price ? '$' + c.total_price.toLocaleString('en-US', {minimumFractionDigits: 2}) : '—';
const serverCount = c.server_count ? c.server_count : 1;
const username = c.user ? c.user.username : '—';
html += '<tr class="hover:bg-gray-50">';
html += '<td class="px-3 py-2 text-sm text-gray-500">' + date + '</td>';
html += '<td class="px-3 py-2 text-sm font-medium">' + escapeHtml(c.name) + '</td>';
html += '<td class="px-3 py-2 text-sm text-gray-500">' + username + '</td>';
html += '<td class="px-3 py-2 text-sm text-gray-500">' + serverCount + '</td>';
html += '<td class="px-3 py-2 text-sm text-right">' + total + '</td>';
html += '<td class="px-3 py-2 text-sm text-right space-x-2">';
html += '<button onclick="openCloneModal(\'' + c.uuid + '\', \'' + escapeHtml(c.name).replace(/'/g, "\\'") + '\')" class="text-green-600 hover:text-green-800" title="Копировать">';
html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">';
html += '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path>';
html += '</svg>';
html += '</button>';
html += '<button onclick="openRenameModal(\'' + c.uuid + '\', \'' + escapeHtml(c.name).replace(/'/g, "\\'") + '\')" class="text-blue-600 hover:text-blue-800" title="Переименовать">';
html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">';
html += '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path>';
html += '</svg>';
html += '</button>';
html += '<button onclick="deleteConfig(\'' + c.uuid + '\')" class="text-red-600 hover:text-red-800" title="Удалить">';
html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">';
html += '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>';
html += '</svg>';
html += '</button>';
html += '</td></tr>';
});
html += '</tbody></table></div>';
document.getElementById('tab-content').innerHTML = html;
}
document.addEventListener('DOMContentLoaded', () => {
loadTab('alerts');
// Add event listeners for preview updates
document.getElementById('modal-method').addEventListener('change', fetchPreview);
document.getElementById('modal-period').addEventListener('change', fetchPreview);
document.getElementById('modal-coefficient').addEventListener('input', debounceFetchPreview);
document.getElementById('modal-manual-price').addEventListener('input', debounceFetchPreview);
document.getElementById('modal-meta-prices').addEventListener('input', debounceFetchPreview);
});
</script>
{{end}}

View File

@@ -4,15 +4,24 @@
<div class="space-y-4">
<h1 class="text-2xl font-bold">Мои конфигурации</h1>
<div id="configs-list">
<div class="text-center py-8 text-gray-500">Загрузка...</div>
</div>
<div class="mt-4">
<button onclick="openCreateModal()" class="w-full py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium">
+ Создать новую конфигурацию
</button>
</div>
<div id="configs-list">
<div class="text-center py-8 text-gray-500">Загрузка...</div>
</div>
<!-- Pagination -->
<div id="pagination" class="flex justify-between items-center mt-4 pt-4 border-t hidden">
<span id="page-info" class="text-sm text-gray-600"></span>
<div class="flex gap-2">
<button onclick="prevPage()" id="btn-prev" class="px-3 py-1 border rounded text-sm disabled:opacity-50">Назад</button>
<button onclick="nextPage()" id="btn-next" class="px-3 py-1 border rounded text-sm disabled:opacity-50">Вперед</button>
</div>
</div>
</div>
<!-- Modal for creating new configuration -->
@@ -132,8 +141,10 @@ function renderConfigs(configs) {
let html = '<div class="bg-white rounded-lg shadow overflow-hidden"><table class="w-full">';
html += '<thead class="bg-gray-50"><tr>';
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Название</th>';
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Дата</th>';
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Название</th>';
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Цена (за 1 шт)</th>';
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Кол-во</th>';
html += '<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Сумма</th>';
html += '<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Действия</th>';
html += '</tr></thead><tbody class="divide-y">';
@@ -141,14 +152,37 @@ function renderConfigs(configs) {
configs.forEach(c => {
const date = new Date(c.created_at).toLocaleDateString('ru-RU');
const total = c.total_price ? '$' + c.total_price.toLocaleString('en-US', {minimumFractionDigits: 2}) : '—';
const serverCount = c.server_count ? c.server_count : 1;
// Calculate price per unit (total / server count)
let pricePerUnit = '—';
if (c.total_price && serverCount > 0) {
const unitPrice = c.total_price / serverCount;
pricePerUnit = '$' + unitPrice.toLocaleString('en-US', {minimumFractionDigits: 2});
}
html += '<tr class="hover:bg-gray-50">';
html += '<td class="px-4 py-3 text-sm font-medium"><a href="/configurator?uuid=' + c.uuid + '" class="text-blue-600 hover:text-blue-800 hover:underline">' + escapeHtml(c.name) + '</a></td>';
html += '<td class="px-4 py-3 text-sm text-gray-500">' + date + '</td>';
html += '<td class="px-4 py-3 text-sm font-medium"><a href="/configurator?uuid=' + c.uuid + '" class="text-blue-600 hover:text-blue-800 hover:underline">' + escapeHtml(c.name) + '</a></td>';
html += '<td class="px-4 py-3 text-sm text-gray-500">' + pricePerUnit + '</td>';
html += '<td class="px-4 py-3 text-sm text-gray-500">' + serverCount + '</td>';
html += '<td class="px-4 py-3 text-sm text-right">' + total + '</td>';
html += '<td class="px-4 py-3 text-sm text-right space-x-2">';
html += '<button onclick="openCloneModal(\'' + c.uuid + '\', \'' + escapeHtml(c.name).replace(/'/g, "\\'") + '\')" class="text-green-600 hover:text-green-800">Копировать</button>';
html += '<button onclick="openRenameModal(\'' + c.uuid + '\', \'' + escapeHtml(c.name).replace(/'/g, "\\'") + '\')" class="text-blue-600 hover:text-blue-800">Переименовать</button>';
html += '<button onclick="deleteConfig(\'' + c.uuid + '\')" class="text-red-600 hover:text-red-800">Удалить</button>';
html += '<button onclick="openCloneModal(\'' + c.uuid + '\', \'' + escapeHtml(c.name).replace(/'/g, "\\'") + '\')" class="text-green-600 hover:text-green-800" title="Копировать">';
html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">';
html += '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path>';
html += '</svg>';
html += '</button>';
html += '<button onclick="openRenameModal(\'' + c.uuid + '\', \'' + escapeHtml(c.name).replace(/'/g, "\\'") + '\')" class="text-blue-600 hover:text-blue-800" title="Переименовать">';
html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">';
html += '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path>';
html += '</svg>';
html += '</button>';
html += '<button onclick="deleteConfig(\'' + c.uuid + '\')" class="text-red-600 hover:text-red-800" title="Удалить">';
html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">';
html += '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>';
html += '</svg>';
html += '</button>';
html += '</td></tr>';
});
@@ -322,7 +356,8 @@ async function createConfig() {
body: JSON.stringify({
name: name,
items: [],
notes: ''
notes: '',
server_count: 1
})
});
@@ -386,6 +421,69 @@ document.getElementById('clone-input').addEventListener('keydown', function(e) {
}
});
// Pagination functions
let currentPage = 1;
let totalPages = 1;
let perPage = 20;
function prevPage() {
if (currentPage > 1) {
currentPage--;
loadConfigs();
}
}
function nextPage() {
if (currentPage < totalPages) {
currentPage++;
loadConfigs();
}
}
function updatePagination(total) {
totalPages = Math.ceil(total / perPage);
document.getElementById('page-info').textContent =
'Страница ' + currentPage + ' из ' + totalPages + ' (всего: ' + total + ')';
document.getElementById('btn-prev').disabled = currentPage <= 1;
document.getElementById('btn-next').disabled = currentPage >= totalPages;
document.getElementById('pagination').classList.remove('hidden');
}
// Load configs with pagination
async function loadConfigs() {
const token = localStorage.getItem('token');
if (!token) {
document.getElementById('configs-list').innerHTML =
'<div class="bg-white rounded-lg shadow p-8 text-center"><a href="/login" class="text-blue-600">Войдите для просмотра</a></div>';
return;
}
try {
const resp = await fetch('/api/configs?page=' + currentPage + '&per_page=' + perPage, {
headers: {'Authorization': 'Bearer ' + token}
});
if (resp.status === 401) {
logout();
return;
}
if (resp.status === 403) {
document.getElementById('configs-list').innerHTML =
'<div class="bg-white rounded-lg shadow p-8 text-center text-red-600">Нет доступа</div>';
return;
}
const data = await resp.json();
renderConfigs(data.configurations || []);
updatePagination(data.total);
} catch(e) {
document.getElementById('configs-list').innerHTML =
'<div class="bg-white rounded-lg shadow p-8 text-center text-red-600">Ошибка загрузки</div>';
}
}
document.addEventListener('DOMContentLoaded', loadConfigs);
</script>
{{end}}

View File

@@ -21,6 +21,21 @@
</div>
</div>
<!-- Server count input -->
<div class="bg-white rounded-lg shadow p-4 mb-4">
<div class="flex items-center space-x-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Количество серверов</label>
<input type="number" id="server-count" min="1" value="1"
class="w-20 px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500"
onchange="updateServerCount()">
</div>
<div class="text-sm text-gray-500">
<span id="server-count-info">Всего: <span id="total-server-count">1</span> сервер(а)</span>
</div>
</div>
</div>
<!-- Category Tabs -->
<div class="bg-white rounded-lg shadow">
<div class="border-b">
@@ -204,6 +219,7 @@ let allComponents = [];
let cart = [];
let categoryOrderMap = {}; // Category code -> display_order mapping
let autoSaveTimeout = null; // Timeout for debounced autosave
let serverCount = 1; // Server count for the configuration
// Autocomplete state
let autocompleteInput = null;
@@ -280,6 +296,11 @@ document.addEventListener('DOMContentLoaded', async function() {
document.getElementById('config-name').textContent = config.name;
document.getElementById('save-buttons').classList.remove('hidden');
// Set server count from config
serverCount = config.server_count || 1;
document.getElementById('server-count').value = serverCount;
document.getElementById('total-server-count').textContent = serverCount;
if (config.items && config.items.length > 0) {
cart = config.items.map(item => ({
lot_name: item.lot_name,
@@ -323,6 +344,22 @@ async function loadAllComponents() {
}
}
function updateServerCount() {
const serverCountInput = document.getElementById('server-count');
const newCount = parseInt(serverCountInput.value) || 1;
serverCount = Math.max(1, newCount);
serverCountInput.value = serverCount;
// Update total server count display
document.getElementById('total-server-count').textContent = serverCount;
// Update cart UI to reflect the server count
updateCartUI();
// Trigger auto-save
triggerAutoSave();
}
function getCategoryFromLotName(lotName) {
const parts = lotName.split('_');
return parts[0] || '';
@@ -1081,6 +1118,9 @@ async function saveConfig(showNotification = true) {
const customPriceValue = parseFloat(customPriceInput.value);
const customPrice = customPriceValue > 0 ? customPriceValue : null;
// Get server count
const serverCountValue = serverCount;
try {
const resp = await fetch('/api/configs/' + configUUID, {
method: 'PUT',
@@ -1092,7 +1132,8 @@ async function saveConfig(showNotification = true) {
name: configName,
items: cart,
custom_price: customPrice,
notes: ''
notes: '',
server_count: serverCountValue
})
});