Добавлены сортировка по категориям, секции PCI и автосохранение

Основные изменения:

1. CSV экспорт и веб-интерфейс:
   - Компоненты теперь сортируются по иерархии категорий (display_order)
   - Категории отображаются в правильном порядке: BB, CPU, MEM, GPU и т.д.
   - Компоненты без категории отображаются в конце

2. Раздел PCI в конфигураторе:
   - Разделен на секции: GPU/DPU, NIC/HCA, HBA
   - Улучшена навигация и выбор компонентов

3. Сохранение "своей цены":
   - Добавлено поле custom_price в модель Configuration
   - Создана миграция 002_add_custom_price.sql
   - "Своя цена" сохраняется при сохранении конфигурации
   - При загрузке конфигурации восстанавливается сохраненная цена

4. Автосохранение:
   - Конфигурация автоматически сохраняется через 1 секунду после изменений
   - Debounce предотвращает избыточные запросы
   - Автосохранение работает для всех изменений (компоненты, количество, цена)

5. Дополнительно:
   - Добавлен cmd/importer для импорта метаданных из таблицы lot
   - Создан скрипт apply_migration.sh для применения миграций
   - Оптимизирована работа с категориями в ExportService

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Mikhail Chusavitin
2026-01-30 17:48:44 +03:00
parent db37040399
commit d32b1c5d0c
16 changed files with 971 additions and 168 deletions

27
apply_migration.sh Executable file
View File

@@ -0,0 +1,27 @@
#!/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

160
cmd/importer/main.go Normal file
View File

@@ -0,0 +1,160 @@
package main
import (
"flag"
"log"
"strings"
"git.mchus.pro/mchus/quoteforge/internal/config"
"git.mchus.pro/mchus/quoteforge/internal/models"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
func main() {
configPath := flag.String("config", "config.yaml", "path to config file")
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)
}
log.Println("Connected to database")
// Ensure tables exist
if err := models.Migrate(db); err != nil {
log.Fatalf("Migration failed: %v", err)
}
if err := models.SeedCategories(db); err != nil {
log.Fatalf("Seeding categories failed: %v", err)
}
// Load categories for lookup
var categories []models.Category
db.Find(&categories)
categoryMap := make(map[string]uint)
for _, c := range categories {
categoryMap[c.Code] = c.ID
}
log.Printf("Loaded %d categories", len(categories))
// Get all lots
var lots []models.Lot
if err := db.Find(&lots).Error; err != nil {
log.Fatalf("Failed to load lots: %v", err)
}
log.Printf("Found %d lots to import", len(lots))
// Import each lot
var imported, skipped, updated int
for _, lot := range lots {
category, model := ParsePartNumber(lot.LotName)
var categoryID *uint
if id, ok := categoryMap[category]; ok && id > 0 {
categoryID = &id
} else {
// Try to find by prefix match
for code, id := range categoryMap {
if strings.HasPrefix(category, code) {
categoryID = &id
break
}
}
}
// Check if already exists
var existing models.LotMetadata
result := db.Where("lot_name = ?", lot.LotName).First(&existing)
if result.Error == gorm.ErrRecordNotFound {
// Check if there are prices in the last 90 days
var recentPriceCount int64
db.Model(&models.LotLog{}).
Where("lot = ? AND date >= DATE_SUB(NOW(), INTERVAL 90 DAY)", lot.LotName).
Count(&recentPriceCount)
// Default to 90 days, but use "all time" (0) if no recent prices
periodDays := 90
if recentPriceCount == 0 {
periodDays = 0
}
// Create new
metadata := models.LotMetadata{
LotName: lot.LotName,
CategoryID: categoryID,
Model: model,
PricePeriodDays: periodDays,
}
if err := db.Create(&metadata).Error; err != nil {
log.Printf("Failed to create metadata for %s: %v", lot.LotName, err)
continue
}
imported++
} else if result.Error == nil {
// Update if needed
needsUpdate := false
if existing.Model == "" {
existing.Model = model
needsUpdate = true
}
if existing.CategoryID == nil {
existing.CategoryID = categoryID
needsUpdate = true
}
// Check if using default period (90 days) but no recent prices
if existing.PricePeriodDays == 90 {
var recentPriceCount int64
db.Model(&models.LotLog{}).
Where("lot = ? AND date >= DATE_SUB(NOW(), INTERVAL 90 DAY)", lot.LotName).
Count(&recentPriceCount)
if recentPriceCount == 0 {
existing.PricePeriodDays = 0
needsUpdate = true
}
}
if needsUpdate {
db.Save(&existing)
updated++
} else {
skipped++
}
}
}
log.Printf("Import complete: %d imported, %d updated, %d skipped", imported, updated, skipped)
// Show final counts
var metadataCount int64
db.Model(&models.LotMetadata{}).Count(&metadataCount)
log.Printf("Total metadata records: %d", metadataCount)
}
// ParsePartNumber extracts category and model from lot_name
// Examples:
// "CPU_AMD_9654" → category="CPU", model="AMD_9654"
// "MB_INTEL_4.Sapphire_2S" → category="MB", model="INTEL_4.Sapphire_2S"
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
}

View File

@@ -170,7 +170,7 @@ func setupRouter(db *gorm.DB, cfg *config.Config) (*gin.Engine, error) {
componentService := services.NewComponentService(componentRepo, categoryRepo, statsRepo) componentService := services.NewComponentService(componentRepo, categoryRepo, statsRepo)
quoteService := services.NewQuoteService(componentRepo, statsRepo, pricingService) quoteService := services.NewQuoteService(componentRepo, statsRepo, pricingService)
configService := services.NewConfigurationService(configRepo, componentRepo, quoteService) configService := services.NewConfigurationService(configRepo, componentRepo, quoteService)
exportService := services.NewExportService(cfg.Export) exportService := services.NewExportService(cfg.Export, categoryRepo)
alertService := alerts.NewService(alertRepo, componentRepo, priceRepo, statsRepo, cfg.Alerts, cfg.Pricing) alertService := alerts.NewService(alertRepo, componentRepo, priceRepo, statsRepo, cfg.Alerts, cfg.Pricing)
// Handlers // Handlers
@@ -294,10 +294,11 @@ func setupRouter(db *gorm.DB, cfg *config.Config) (*gin.Engine, error) {
configs.GET("/:uuid", configHandler.Get) configs.GET("/:uuid", configHandler.Get)
configs.PUT("/:uuid", configHandler.Update) configs.PUT("/:uuid", configHandler.Update)
configs.PATCH("/:uuid/rename", configHandler.Rename) configs.PATCH("/:uuid/rename", configHandler.Rename)
configs.POST("/:uuid/clone", configHandler.Clone)
configs.DELETE("/:uuid", configHandler.Delete) configs.DELETE("/:uuid", configHandler.Delete)
configs.GET("/:uuid/export", configHandler.ExportJSON) // configs.GET("/:uuid/export", configHandler.ExportJSON)
configs.GET("/:uuid/csv", exportHandler.ExportConfigCSV) configs.GET("/:uuid/csv", exportHandler.ExportConfigCSV)
configs.POST("/import", configHandler.ImportJSON) // configs.POST("/import", configHandler.ImportJSON)
} }
} }

View File

@@ -1,8 +1,7 @@
package handlers package handlers
import ( import (
"fmt"
"io"
"net/http" "net/http"
"strconv" "strconv"
@@ -153,41 +152,70 @@ func (h *ConfigurationHandler) Rename(c *gin.Context) {
c.JSON(http.StatusOK, config) c.JSON(http.StatusOK, config)
} }
func (h *ConfigurationHandler) ExportJSON(c *gin.Context) { type CloneConfigRequest struct {
Name string `json:"name" binding:"required"`
}
func (h *ConfigurationHandler) Clone(c *gin.Context) {
userID := middleware.GetUserID(c) userID := middleware.GetUserID(c)
uuid := c.Param("uuid") uuid := c.Param("uuid")
config, err := h.configService.GetByUUID(uuid, userID) var req CloneConfigRequest
if err != nil { if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
data, err := h.configService.ExportJSON(uuid, userID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
filename := fmt.Sprintf("%s %s SPEC.json", config.CreatedAt.Format("2006-01-02"), config.Name)
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
c.Data(http.StatusOK, "application/json", data)
}
func (h *ConfigurationHandler) ImportJSON(c *gin.Context) {
userID := middleware.GetUserID(c)
data, err := io.ReadAll(c.Request.Body)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "failed to read body"})
return
}
config, err := h.configService.ImportJSON(userID, data)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
config, err := h.configService.Clone(uuid, userID, req.Name)
if err != nil {
status := http.StatusInternalServerError
if err == services.ErrConfigNotFound {
status = http.StatusNotFound
} else if err == services.ErrConfigForbidden {
status = http.StatusForbidden
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, config) c.JSON(http.StatusCreated, config)
} }
// func (h *ConfigurationHandler) ExportJSON(c *gin.Context) {
// userID := middleware.GetUserID(c)
// uuid := c.Param("uuid")
//
// config, err := h.configService.GetByUUID(uuid, userID)
// if err != nil {
// c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
// return
// }
//
// data, err := h.configService.ExportJSON(uuid, userID)
// if err != nil {
// c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
// return
// }
//
// filename := fmt.Sprintf("%s %s SPEC.json", config.CreatedAt.Format("2006-01-02"), config.Name)
// c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
// c.Data(http.StatusOK, "application/json", data)
// }
// func (h *ConfigurationHandler) ImportJSON(c *gin.Context) {
// userID := middleware.GetUserID(c)
//
// data, err := io.ReadAll(c.Request.Body)
// if err != nil {
// c.JSON(http.StatusBadRequest, gin.H{"error": "failed to read body"})
// return
// }
//
// config, err := h.configService.ImportJSON(userID, data)
// if err != nil {
// c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
// return
// }
//
// c.JSON(http.StatusCreated, config)
// }

View File

@@ -7,7 +7,6 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"git.mchus.pro/mchus/quoteforge/internal/middleware" "git.mchus.pro/mchus/quoteforge/internal/middleware"
"git.mchus.pro/mchus/quoteforge/internal/models"
"git.mchus.pro/mchus/quoteforge/internal/services" "git.mchus.pro/mchus/quoteforge/internal/services"
) )
@@ -65,11 +64,26 @@ func (h *ExportHandler) buildExportData(req *ExportRequest) *services.ExportData
for i, item := range req.Items { for i, item := range req.Items {
itemTotal := item.UnitPrice * float64(item.Quantity) itemTotal := item.UnitPrice * float64(item.Quantity)
items[i] = services.ExportItem{
LotName: item.LotName, // Получаем информацию о компоненте для заполнения категории и описания
Quantity: item.Quantity, componentView, err := h.componentService.GetByLotName(item.LotName)
UnitPrice: item.UnitPrice, if err != nil {
TotalPrice: itemTotal, // Если не удалось получить информацию о компоненте, используем только основные данные
items[i] = services.ExportItem{
LotName: item.LotName,
Quantity: item.Quantity,
UnitPrice: item.UnitPrice,
TotalPrice: itemTotal,
}
} else {
items[i] = services.ExportItem{
LotName: item.LotName,
Description: componentView.Description,
Category: componentView.Category,
Quantity: item.Quantity,
UnitPrice: item.UnitPrice,
TotalPrice: itemTotal,
}
} }
total += itemTotal total += itemTotal
} }
@@ -93,7 +107,7 @@ func (h *ExportHandler) ExportConfigCSV(c *gin.Context) {
return return
} }
data := h.configToExportData(config) data := h.exportService.ConfigToExportData(config, h.componentService)
csvData, err := h.exportService.ToCSV(data) csvData, err := h.exportService.ToCSV(data)
if err != nil { if err != nil {
@@ -105,27 +119,3 @@ func (h *ExportHandler) ExportConfigCSV(c *gin.Context) {
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename)) c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
c.Data(http.StatusOK, "text/csv; charset=utf-8", csvData) c.Data(http.StatusOK, "text/csv; charset=utf-8", csvData)
} }
func (h *ExportHandler) configToExportData(config *models.Configuration) *services.ExportData {
items := make([]services.ExportItem, len(config.Items))
var total float64
for i, item := range config.Items {
itemTotal := item.UnitPrice * float64(item.Quantity)
items[i] = services.ExportItem{
LotName: item.LotName,
Quantity: item.Quantity,
UnitPrice: item.UnitPrice,
TotalPrice: itemTotal,
}
total += itemTotal
}
return &services.ExportData{
Name: config.Name,
Items: items,
Total: total,
Notes: config.Notes,
CreatedAt: config.CreatedAt,
}
}

View File

@@ -13,17 +13,32 @@ func (Category) TableName() string {
return "qt_categories" return "qt_categories"
} }
// DefaultCategories defines the standard categories with display order
// Order: BB, CPU, MEM, GPU, SSD, RAID, HBA, NIC, PSU, RISERS, ACC, and others
var DefaultCategories = []Category{ var DefaultCategories = []Category{
{Code: "MB", Name: "Motherboard", NameRu: "Материнская плата", DisplayOrder: 1, IsRequired: true}, {Code: "BB", Name: "Barebone", NameRu: "Шасси", DisplayOrder: 1, IsRequired: true},
{Code: "CPU", Name: "Processor", NameRu: "Процессор", DisplayOrder: 2, IsRequired: true}, {Code: "CPU", Name: "Processor", NameRu: "Процессор", DisplayOrder: 2, IsRequired: true},
{Code: "MEM", Name: "Memory", NameRu: "Оперативная память", DisplayOrder: 3, IsRequired: true}, {Code: "MEM", Name: "Memory", NameRu: "Оперативная память", DisplayOrder: 3, IsRequired: true},
{Code: "GPU", Name: "Graphics Card", NameRu: "Видеокарта", DisplayOrder: 4}, {Code: "GPU", Name: "Graphics Card", NameRu: "Видеокарта", DisplayOrder: 4},
{Code: "SSD", Name: "SSD Storage", NameRu: "SSD накопитель", DisplayOrder: 5}, {Code: "SSD", Name: "SSD Storage", NameRu: "SSD накопитель", DisplayOrder: 5},
{Code: "HDD", Name: "HDD Storage", NameRu: "HDD накопитель", DisplayOrder: 6}, {Code: "RAID", Name: "RAID Controller", NameRu: "RAID контроллер", DisplayOrder: 6},
{Code: "RAID", Name: "RAID Controller", NameRu: "RAID контроллер", DisplayOrder: 7}, {Code: "HBA", Name: "HBA Adapter", NameRu: "HBA адаптер", DisplayOrder: 7},
{Code: "NIC", Name: "Network Card", NameRu: "Сетевая карта", DisplayOrder: 8}, {Code: "NIC", Name: "Network Card", NameRu: "Сетевая карта", DisplayOrder: 8},
{Code: "HCA", Name: "HCA Adapter", NameRu: "HCA адаптер", DisplayOrder: 9}, {Code: "PSU", Name: "Power Supply", NameRu: "Блок питания", DisplayOrder: 9},
{Code: "HBA", Name: "HBA Adapter", NameRu: "HBA адаптер", DisplayOrder: 10}, {Code: "RISERS", Name: "Risers", NameRu: "Райзеры", DisplayOrder: 10},
{Code: "DPU", Name: "DPU", NameRu: "DPU", DisplayOrder: 11}, {Code: "ACC", Name: "Accessories", NameRu: "Аксессуары", DisplayOrder: 11},
{Code: "PS", Name: "Power Supply", NameRu: "Блок питания", DisplayOrder: 12}, // Additional categories
{Code: "MB", Name: "Motherboard", NameRu: "Материнская плата", DisplayOrder: 12},
{Code: "HDD", Name: "HDD Storage", NameRu: "HDD накопитель", DisplayOrder: 13},
{Code: "HCA", Name: "HCA Adapter", NameRu: "HCA адаптер", DisplayOrder: 14},
{Code: "DPU", Name: "DPU", NameRu: "DPU", DisplayOrder: 15},
{Code: "M2", Name: "M.2 Storage", NameRu: "M.2 накопитель", DisplayOrder: 16},
{Code: "EDSFF", Name: "EDSFF Storage", NameRu: "EDSFF накопитель", DisplayOrder: 17},
{Code: "HHHL", Name: "HHHL Storage", NameRu: "HHHL накопитель", DisplayOrder: 18},
{Code: "PS", Name: "Power Supply (Legacy)", NameRu: "Блок питания", DisplayOrder: 19},
{Code: "CARD", Name: "Cards", NameRu: "Карты", DisplayOrder: 20},
} }
// MaxKnownDisplayOrder is the highest display order for known categories
// New categories will get display order starting from this + 1
const MaxKnownDisplayOrder = 100

View File

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

View File

@@ -2,10 +2,11 @@ package models
import "time" import "time"
// Lot represents existing lot table (READ-ONLY) // Lot represents existing lot table
type Lot struct { type Lot struct {
LotName string `gorm:"column:lot_name;primaryKey;size:255"` LotName string `gorm:"column:lot_name;primaryKey;size:255" json:"lot_name"`
LotDescription string `gorm:"column:lot_description;size:10000"` LotDescription string `gorm:"column:lot_description;size:10000" json:"lot_description"`
LotCategory *string `gorm:"column:lot_category;size:50" json:"lot_category"`
} }
func (Lot) TableName() string { func (Lot) TableName() string {

View File

@@ -36,3 +36,41 @@ func (r *CategoryRepository) GetByID(id uint) (*models.Category, error) {
} }
return &category, nil return &category, nil
} }
// CreateIfNotExists creates a new category if it doesn't exist, returns existing one if it does
func (r *CategoryRepository) CreateIfNotExists(code string) (*models.Category, error) {
// Try to find existing
existing, err := r.GetByCode(code)
if err == nil {
return existing, nil
}
// Get max display order to put new category at the end
var maxOrder int
r.db.Model(&models.Category{}).Select("COALESCE(MAX(display_order), 0)").Scan(&maxOrder)
// Create new category
newCat := &models.Category{
Code: code,
Name: code, // Use code as name initially
NameRu: code,
DisplayOrder: maxOrder + 1,
IsRequired: false,
}
if err := r.db.Create(newCat).Error; err != nil {
return nil, err
}
return newCat, nil
}
// Create creates a new category
func (r *CategoryRepository) Create(category *models.Category) error {
return r.db.Create(category).Error
}
// Update updates an existing category
func (r *CategoryRepository) Update(category *models.Category) error {
return r.db.Save(category).Error
}

View File

@@ -152,12 +152,21 @@ func (s *ComponentService) ImportFromLot() (int, error) {
categoryMap := make(map[string]uint) categoryMap := make(map[string]uint)
for _, cat := range categories { for _, cat := range categories {
categoryMap[cat.Code] = cat.ID categoryMap[strings.ToUpper(cat.Code)] = cat.ID
} }
imported := 0 imported := 0
for _, lot := range lots { for _, lot := range lots {
category, model := ParsePartNumber(lot.LotName) // Use lot_category from database if available, otherwise parse from lot_name
var category string
if lot.LotCategory != nil && *lot.LotCategory != "" {
category = strings.ToUpper(*lot.LotCategory)
} else {
category, _ = ParsePartNumber(lot.LotName)
category = strings.ToUpper(category)
}
_, model := ParsePartNumber(lot.LotName)
metadata := &models.LotMetadata{ metadata := &models.LotMetadata{
LotName: lot.LotName, LotName: lot.LotName,
@@ -167,6 +176,12 @@ func (s *ComponentService) ImportFromLot() (int, error) {
if catID, ok := categoryMap[category]; ok { if catID, ok := categoryMap[category]; ok {
metadata.CategoryID = &catID metadata.CategoryID = &catID
} else {
// Create new category if it doesn't exist
newCat, err := s.categoryRepo.CreateIfNotExists(category)
if err == nil && newCat != nil {
metadata.CategoryID = &newCat.ID
}
} }
if err := s.componentRepo.Create(metadata); err != nil { if err := s.componentRepo.Create(metadata); err != nil {

View File

@@ -1,7 +1,6 @@
package services package services
import ( import (
"encoding/json"
"errors" "errors"
"github.com/google/uuid" "github.com/google/uuid"
@@ -33,23 +32,25 @@ func NewConfigurationService(
} }
type CreateConfigRequest struct { type CreateConfigRequest struct {
Name string `json:"name"` Name string `json:"name"`
Items models.ConfigItems `json:"items"` Items models.ConfigItems `json:"items"`
Notes string `json:"notes"` CustomPrice *float64 `json:"custom_price"`
IsTemplate bool `json:"is_template"` Notes string `json:"notes"`
IsTemplate bool `json:"is_template"`
} }
func (s *ConfigurationService) Create(userID uint, req *CreateConfigRequest) (*models.Configuration, error) { func (s *ConfigurationService) Create(userID uint, req *CreateConfigRequest) (*models.Configuration, error) {
total := req.Items.Total() total := req.Items.Total()
config := &models.Configuration{ config := &models.Configuration{
UUID: uuid.New().String(), UUID: uuid.New().String(),
UserID: userID, UserID: userID,
Name: req.Name, Name: req.Name,
Items: req.Items, Items: req.Items,
TotalPrice: &total, TotalPrice: &total,
Notes: req.Notes, CustomPrice: req.CustomPrice,
IsTemplate: req.IsTemplate, Notes: req.Notes,
IsTemplate: req.IsTemplate,
} }
if err := s.configRepo.Create(config); err != nil { if err := s.configRepo.Create(config); err != nil {
@@ -91,6 +92,7 @@ func (s *ConfigurationService) Update(uuid string, userID uint, req *CreateConfi
config.Name = req.Name config.Name = req.Name
config.Items = req.Items config.Items = req.Items
config.TotalPrice = &total config.TotalPrice = &total
config.CustomPrice = req.CustomPrice
config.Notes = req.Notes config.Notes = req.Notes
config.IsTemplate = req.IsTemplate config.IsTemplate = req.IsTemplate
@@ -133,6 +135,33 @@ func (s *ConfigurationService) Rename(uuid string, userID uint, newName string)
return config, nil return config, nil
} }
func (s *ConfigurationService) Clone(configUUID string, userID uint, newName string) (*models.Configuration, error) {
original, err := s.GetByUUID(configUUID, userID)
if err != nil {
return nil, err
}
// Create copy with new UUID and name
total := original.Items.Total()
clone := &models.Configuration{
UUID: uuid.New().String(),
UserID: userID,
Name: newName,
Items: original.Items,
TotalPrice: &total,
CustomPrice: original.CustomPrice,
Notes: original.Notes,
IsTemplate: false, // Clone is never a template
}
if err := s.configRepo.Create(clone); err != nil {
return nil, err
}
return clone, nil
}
func (s *ConfigurationService) ListByUser(userID uint, page, perPage int) ([]models.Configuration, int64, error) { func (s *ConfigurationService) ListByUser(userID uint, page, perPage int) ([]models.Configuration, int64, error) {
if page < 1 { if page < 1 {
page = 1 page = 1
@@ -157,39 +186,39 @@ func (s *ConfigurationService) ListTemplates(page, perPage int) ([]models.Config
return s.configRepo.ListTemplates(offset, perPage) return s.configRepo.ListTemplates(offset, perPage)
} }
// Export configuration as JSON // // Export configuration as JSON
type ConfigExport struct { // type ConfigExport struct {
Name string `json:"name"` // Name string `json:"name"`
Notes string `json:"notes"` // Notes string `json:"notes"`
Items models.ConfigItems `json:"items"` // Items models.ConfigItems `json:"items"`
} // }
//
func (s *ConfigurationService) ExportJSON(uuid string, userID uint) ([]byte, error) { // func (s *ConfigurationService) ExportJSON(uuid string, userID uint) ([]byte, error) {
config, err := s.GetByUUID(uuid, userID) // config, err := s.GetByUUID(uuid, userID)
if err != nil { // if err != nil {
return nil, err // return nil, err
} // }
//
export := ConfigExport{ // export := ConfigExport{
Name: config.Name, // Name: config.Name,
Notes: config.Notes, // Notes: config.Notes,
Items: config.Items, // Items: config.Items,
} // }
//
return json.MarshalIndent(export, "", " ") // return json.MarshalIndent(export, "", " ")
} // }
//
func (s *ConfigurationService) ImportJSON(userID uint, data []byte) (*models.Configuration, error) { // func (s *ConfigurationService) ImportJSON(userID uint, data []byte) (*models.Configuration, error) {
var export ConfigExport // var export ConfigExport
if err := json.Unmarshal(data, &export); err != nil { // if err := json.Unmarshal(data, &export); err != nil {
return nil, err // return nil, err
} // }
//
req := &CreateConfigRequest{ // req := &CreateConfigRequest{
Name: export.Name, // Name: export.Name,
Notes: export.Notes, // Notes: export.Notes,
Items: export.Items, // Items: export.Items,
} // }
//
return s.Create(userID, req) // return s.Create(userID, req)
} // }

View File

@@ -8,14 +8,19 @@ import (
"git.mchus.pro/mchus/quoteforge/internal/config" "git.mchus.pro/mchus/quoteforge/internal/config"
"git.mchus.pro/mchus/quoteforge/internal/models" "git.mchus.pro/mchus/quoteforge/internal/models"
"git.mchus.pro/mchus/quoteforge/internal/repository"
) )
type ExportService struct { type ExportService struct {
config config.ExportConfig config config.ExportConfig
categoryRepo *repository.CategoryRepository
} }
func NewExportService(cfg config.ExportConfig) *ExportService { func NewExportService(cfg config.ExportConfig, categoryRepo *repository.CategoryRepository) *ExportService {
return &ExportService{config: cfg} return &ExportService{
config: cfg,
categoryRepo: categoryRepo,
}
} }
type ExportData struct { type ExportData struct {
@@ -45,8 +50,41 @@ func (s *ExportService) ToCSV(data *ExportData) ([]byte, error) {
return nil, err return nil, err
} }
// Get category hierarchy for sorting
categoryOrder := make(map[string]int)
if s.categoryRepo != nil {
categories, err := s.categoryRepo.GetAll()
if err == nil {
for _, cat := range categories {
categoryOrder[cat.Code] = cat.DisplayOrder
}
}
}
// Sort items by category display order
sortedItems := make([]ExportItem, len(data.Items))
copy(sortedItems, data.Items)
// Sort using category display order (items without category go to the end)
for i := 0; i < len(sortedItems)-1; i++ {
for j := i + 1; j < len(sortedItems); j++ {
orderI, hasI := categoryOrder[sortedItems[i].Category]
orderJ, hasJ := categoryOrder[sortedItems[j].Category]
// Items without category go to the end
if !hasI && hasJ {
sortedItems[i], sortedItems[j] = sortedItems[j], sortedItems[i]
} else if hasI && hasJ {
// Both have categories, sort by display order
if orderI > orderJ {
sortedItems[i], sortedItems[j] = sortedItems[j], sortedItems[i]
}
}
}
}
// Items // Items
for _, item := range data.Items { for _, item := range sortedItems {
row := []string{ row := []string{
item.LotName, item.LotName,
item.Description, item.Description,
@@ -69,17 +107,32 @@ func (s *ExportService) ToCSV(data *ExportData) ([]byte, error) {
return buf.Bytes(), w.Error() return buf.Bytes(), w.Error()
} }
func (s *ExportService) ConfigToExportData(config *models.Configuration) *ExportData { func (s *ExportService) ConfigToExportData(config *models.Configuration, componentService *ComponentService) *ExportData {
items := make([]ExportItem, len(config.Items)) items := make([]ExportItem, len(config.Items))
var total float64 var total float64
for i, item := range config.Items { for i, item := range config.Items {
itemTotal := item.UnitPrice * float64(item.Quantity) itemTotal := item.UnitPrice * float64(item.Quantity)
items[i] = ExportItem{
LotName: item.LotName, // Получаем информацию о компоненте для заполнения категории
Quantity: item.Quantity, componentView, err := componentService.GetByLotName(item.LotName)
UnitPrice: item.UnitPrice, if err != nil {
TotalPrice: itemTotal, // Если не удалось получить информацию о компоненте, используем только основные данные
items[i] = ExportItem{
LotName: item.LotName,
Quantity: item.Quantity,
UnitPrice: item.UnitPrice,
TotalPrice: itemTotal,
}
} else {
items[i] = ExportItem{
LotName: item.LotName,
Description: componentView.Description,
Category: componentView.Category,
Quantity: item.Quantity,
UnitPrice: item.UnitPrice,
TotalPrice: itemTotal,
}
} }
total += itemTotal total += itemTotal
} }

View File

@@ -0,0 +1,11 @@
-- Migration: Add lot_category column to lot table
-- Run this migration manually on the database
-- Add lot_category column to lot table
ALTER TABLE lot ADD COLUMN lot_category VARCHAR(50) DEFAULT NULL;
-- Create index for faster lookups
CREATE INDEX idx_lot_category ON lot(lot_category);
-- Update existing lots: extract category from lot_name (first part before underscore)
UPDATE lot SET lot_category = SUBSTRING_INDEX(lot_name, '_', 1) WHERE lot_category IS NULL;

View File

@@ -0,0 +1,2 @@
-- Add custom_price column to qt_configurations table
ALTER TABLE qt_configurations ADD COLUMN custom_price DECIMAL(12,2) NULL COMMENT 'User-defined custom total price';

View File

@@ -64,6 +64,31 @@
</div> </div>
</div> </div>
<!-- Modal for cloning configuration -->
<div id="clone-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
<div class="bg-white rounded-lg shadow-xl w-full max-w-md mx-4 p-6">
<h2 class="text-xl font-semibold mb-4">Копировать конфигурацию</h2>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Название копии</label>
<input type="text" id="clone-input" placeholder="Введите название"
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<input type="hidden" id="clone-uuid">
</div>
</div>
<div class="flex justify-end space-x-3 mt-6">
<button onclick="closeCloneModal()" class="px-4 py-2 text-gray-600 hover:text-gray-800">
Отмена
</button>
<button onclick="cloneConfig()" class="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700">
Копировать
</button>
</div>
</div>
</div>
<script> <script>
async function loadConfigs() { async function loadConfigs() {
const token = localStorage.getItem('token'); const token = localStorage.getItem('token');
@@ -121,6 +146,7 @@ function renderConfigs(configs) {
html += '<td class="px-4 py-3 text-sm text-gray-500">' + date + '</td>'; html += '<td class="px-4 py-3 text-sm text-gray-500">' + date + '</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">' + total + '</td>';
html += '<td class="px-4 py-3 text-sm text-right space-x-2">'; 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="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="deleteConfig(\'' + c.uuid + '\')" class="text-red-600 hover:text-red-800">Удалить</button>';
html += '</td></tr>'; html += '</td></tr>';
@@ -203,6 +229,63 @@ async function renameConfig() {
} }
} }
function openCloneModal(uuid, currentName) {
const token = localStorage.getItem('token');
if (!token) {
window.location.href = '/login';
return;
}
document.getElementById('clone-uuid').value = uuid;
document.getElementById('clone-input').value = currentName + ' (копия)';
document.getElementById('clone-modal').classList.remove('hidden');
document.getElementById('clone-modal').classList.add('flex');
document.getElementById('clone-input').focus();
document.getElementById('clone-input').select();
}
function closeCloneModal() {
document.getElementById('clone-modal').classList.add('hidden');
document.getElementById('clone-modal').classList.remove('flex');
}
async function cloneConfig() {
const token = localStorage.getItem('token');
const uuid = document.getElementById('clone-uuid').value;
const name = document.getElementById('clone-input').value.trim();
if (!name) {
alert('Введите название');
return;
}
try {
const resp = await fetch('/api/configs/' + uuid + '/clone', {
method: 'POST',
headers: {
'Authorization': 'Bearer ' + token,
'Content-Type': 'application/json'
},
body: JSON.stringify({ name: name })
});
if (resp.status === 401) {
logout();
return;
}
if (!resp.ok) {
const err = await resp.json();
alert('Ошибка: ' + (err.error || 'Не удалось скопировать'));
return;
}
closeCloneModal();
loadConfigs();
} catch(e) {
alert('Ошибка копирования');
}
}
function openCreateModal() { function openCreateModal() {
const token = localStorage.getItem('token'); const token = localStorage.getItem('token');
if (!token) { if (!token) {
@@ -274,11 +357,18 @@ document.getElementById('rename-modal').addEventListener('click', function(e) {
} }
}); });
document.getElementById('clone-modal').addEventListener('click', function(e) {
if (e.target === this) {
closeCloneModal();
}
});
// Close modal on Escape key // Close modal on Escape key
document.addEventListener('keydown', function(e) { document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') { if (e.key === 'Escape') {
closeCreateModal(); closeCreateModal();
closeRenameModal(); closeRenameModal();
closeCloneModal();
} }
}); });
@@ -289,6 +379,13 @@ document.getElementById('rename-input').addEventListener('keydown', function(e)
} }
}); });
// Submit clone on Enter key
document.getElementById('clone-input').addEventListener('keydown', function(e) {
if (e.key === 'Enter') {
cloneConfig();
}
});
document.addEventListener('DOMContentLoaded', loadConfigs); document.addEventListener('DOMContentLoaded', loadConfigs);
</script> </script>
{{end}} {{end}}

View File

@@ -81,7 +81,7 @@
<input type="number" id="custom-price-input" step="0.01" min="0" <input type="number" id="custom-price-input" step="0.01" min="0"
placeholder="0.00" placeholder="0.00"
class="flex-1 px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500" class="flex-1 px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500"
oninput="calculateCustomPrice()"> oninput="calculateCustomPrice(); triggerAutoSave();">
<button onclick="clearCustomPrice()" class="px-3 py-2 text-gray-500 hover:text-gray-700"> <button onclick="clearCustomPrice()" class="px-3 py-2 text-gray-500 hover:text-gray-700">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
@@ -149,22 +149,31 @@
</style> </style>
<script> <script>
// Tab configuration // Tab configuration - will be populated dynamically
const TAB_CONFIG = { let TAB_CONFIG = {
base: { base: {
categories: ['MB', 'CPU', 'MEM'], categories: ['MB', 'CPU', 'MEM'],
singleSelect: true, singleSelect: true,
label: 'Base' label: 'Base'
}, },
storage: { storage: {
categories: ['M2', 'SSD', 'HDD', 'EDSFF', 'HHHL'], categories: ['RAID', 'M2', 'SSD', 'HDD', 'EDSFF', 'HHHL'],
singleSelect: false, singleSelect: false,
label: 'Storage' label: 'Storage',
sections: [
{ title: 'RAID Контроллеры', categories: ['RAID'] },
{ title: 'Диски', categories: ['M2', 'SSD', 'HDD', 'EDSFF', 'HHHL'] }
]
}, },
pci: { pci: {
categories: ['HBA', 'HCA', 'NIC', 'GPU', 'RAID', 'DPU'], categories: ['GPU', 'DPU', 'NIC', 'HCA', 'HBA'],
singleSelect: false, singleSelect: false,
label: 'PCI' label: 'PCI',
sections: [
{ title: 'GPU / DPU', categories: ['GPU', 'DPU'] },
{ title: 'NIC / HCA', categories: ['NIC', 'HCA'] },
{ title: 'HBA', categories: ['HBA'] }
]
}, },
power: { power: {
categories: ['PS', 'PSU'], categories: ['PS', 'PSU'],
@@ -183,7 +192,7 @@ const TAB_CONFIG = {
} }
}; };
const ASSIGNED_CATEGORIES = Object.values(TAB_CONFIG) let ASSIGNED_CATEGORIES = Object.values(TAB_CONFIG)
.flatMap(t => t.categories) .flatMap(t => t.categories)
.map(c => c.toUpperCase()); .map(c => c.toUpperCase());
@@ -193,13 +202,51 @@ let configName = '';
let currentTab = 'base'; let currentTab = 'base';
let allComponents = []; let allComponents = [];
let cart = []; let cart = [];
let categoryOrderMap = {}; // Category code -> display_order mapping
let autoSaveTimeout = null; // Timeout for debounced autosave
// Autocomplete state // Autocomplete state
let autocompleteInput = null; let autocompleteInput = null;
let autocompleteCategory = null; let autocompleteCategory = null;
let autocompleteMode = null; // 'single', 'multi', 'section'
let autocompleteIndex = -1; let autocompleteIndex = -1;
let autocompleteFiltered = []; let autocompleteFiltered = [];
// Load categories from API and update tab configuration
async function loadCategoriesFromAPI() {
try {
const resp = await fetch('/api/categories');
const cats = await resp.json();
// Build category order map
categoryOrderMap = {};
cats.forEach(cat => {
categoryOrderMap[cat.code.toUpperCase()] = cat.display_order;
});
// Build list of unassigned categories
const knownCodes = Object.values(TAB_CONFIG)
.flatMap(t => t.categories)
.map(c => c.toUpperCase());
const unassignedCategories = cats
.filter(cat => !knownCodes.includes(cat.code.toUpperCase()))
.sort((a, b) => a.display_order - b.display_order)
.map(cat => cat.code);
// Update "other" tab with unassigned categories
TAB_CONFIG.other.categories = unassignedCategories;
// Rebuild ASSIGNED_CATEGORIES
ASSIGNED_CATEGORIES = Object.values(TAB_CONFIG)
.flatMap(t => t.categories)
.map(c => c.toUpperCase());
} catch(e) {
console.error('Failed to load categories, using defaults', e);
// Will use default configuration if API fails
}
}
// Initialize // Initialize
document.addEventListener('DOMContentLoaded', async function() { document.addEventListener('DOMContentLoaded', async function() {
const token = localStorage.getItem('token'); const token = localStorage.getItem('token');
@@ -209,6 +256,9 @@ document.addEventListener('DOMContentLoaded', async function() {
return; return;
} }
// Load categories first
await loadCategoriesFromAPI();
try { try {
const resp = await fetch('/api/configs/' + configUUID, { const resp = await fetch('/api/configs/' + configUUID, {
headers: {'Authorization': 'Bearer ' + token} headers: {'Authorization': 'Bearer ' + token}
@@ -239,6 +289,11 @@ document.addEventListener('DOMContentLoaded', async function() {
category: item.category || getCategoryFromLotName(item.lot_name) category: item.category || getCategoryFromLotName(item.lot_name)
})); }));
} }
// Restore custom price if saved
if (config.custom_price) {
document.getElementById('custom-price-input').value = config.custom_price.toFixed(2);
}
} catch(e) { } catch(e) {
showToast('Ошибка загрузки конфигурации', 'error'); showToast('Ошибка загрузки конфигурации', 'error');
window.location.href = '/configs'; window.location.href = '/configs';
@@ -325,6 +380,8 @@ function renderTab() {
if (config.singleSelect) { if (config.singleSelect) {
renderSingleSelectTab(config.categories); renderSingleSelectTab(config.categories);
} else if (config.sections) {
renderMultiSelectTabWithSections(config.sections);
} else { } else {
renderMultiSelectTab(components); renderMultiSelectTab(components);
} }
@@ -479,10 +536,120 @@ function renderMultiSelectTab(components) {
document.getElementById('tab-content').innerHTML = html; document.getElementById('tab-content').innerHTML = html;
} }
function renderMultiSelectTabWithSections(sections) {
// Get cart items for this tab
const tabItems = cart.filter(item => {
const cat = (item.category || getCategoryFromLotName(item.lot_name)).toUpperCase();
const tab = getTabForCategory(cat);
return tab === currentTab;
});
let html = '';
let totalComponents = 0;
sections.forEach((section, sectionIdx) => {
// Get components for this section's categories
const sectionCategories = section.categories.map(c => c.toUpperCase());
const sectionComponents = allComponents.filter(comp => {
const category = getComponentCategory(comp);
return sectionCategories.includes(category);
});
totalComponents += sectionComponents.length;
// Get cart items for this section
const sectionItems = tabItems.filter(item => {
const cat = (item.category || getCategoryFromLotName(item.lot_name)).toUpperCase();
return sectionCategories.includes(cat);
});
// Section header
html += `
<div class="mb-6 ${sectionIdx > 0 ? 'mt-6' : ''}">
<h3 class="text-sm font-semibold text-gray-700 mb-3 px-3">${section.title}</h3>
<table class="w-full">
<thead class="bg-gray-50">
<tr>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">LOT</th>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Описание</th>
<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase w-24">Цена</th>
<th class="px-3 py-2 text-center text-xs font-medium text-gray-500 uppercase w-20">Кол-во</th>
<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase w-28">Стоимость</th>
<th class="px-3 py-2 w-10"></th>
</tr>
</thead>
<tbody class="divide-y">
`;
// Render existing cart items for this section
sectionItems.forEach((item) => {
const comp = allComponents.find(c => c.lot_name === item.lot_name);
const total = item.unit_price * item.quantity;
html += `
<tr class="hover:bg-gray-50">
<td class="px-3 py-2 text-sm font-mono">${escapeHtml(item.lot_name)}</td>
<td class="px-3 py-2 text-sm text-gray-500 truncate max-w-xs">${escapeHtml(item.description || comp?.description || '')}</td>
<td class="px-3 py-2 text-sm text-right">$${item.unit_price.toFixed(2)}</td>
<td class="px-3 py-2 text-center">
<input type="number" min="1" value="${item.quantity}"
onchange="updateMultiQuantity('${item.lot_name}', this.value)"
class="w-16 px-2 py-1 border rounded text-center text-sm">
</td>
<td class="px-3 py-2 text-sm text-right font-medium">$${total.toFixed(2)}</td>
<td class="px-3 py-2 text-center">
<button onclick="removeFromCart('${item.lot_name}')" class="text-red-500 hover:text-red-700">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</td>
</tr>
`;
});
// Add empty row for new item in this section
const sectionId = section.categories.join('-');
const categoriesStr = section.categories.join(',');
html += `
<tr class="hover:bg-gray-50 bg-gray-50">
<td class="px-3 py-2" colspan="2">
<div class="autocomplete-wrapper relative">
<input type="text"
id="input-section-${sectionId}"
data-categories="${categoriesStr}"
placeholder="Добавить ${section.title.toLowerCase()}..."
class="w-full px-2 py-1 border rounded text-sm"
onfocus="showAutocompleteSection('${sectionId}', this)"
oninput="filterAutocompleteSection('${sectionId}', this.value, this)"
onkeydown="handleAutocompleteKeySection(event, '${sectionId}')">
</div>
</td>
<td class="px-3 py-2 text-sm text-right text-gray-400" id="new-price-${sectionId}">—</td>
<td class="px-3 py-2 text-center">
<input type="number" min="1" value="1" id="new-qty-${sectionId}"
class="w-16 px-2 py-1 border rounded text-center text-sm">
</td>
<td class="px-3 py-2 text-sm text-right text-gray-400" id="new-total-${sectionId}">—</td>
<td class="px-3 py-2"></td>
</tr>
`;
html += `
</tbody>
</table>
<p class="text-center text-sm text-gray-500 mt-2">Доступно: ${sectionComponents.length}</p>
</div>
`;
});
document.getElementById('tab-content').innerHTML = html;
}
// Autocomplete for single select (Base tab) // Autocomplete for single select (Base tab)
function showAutocomplete(category, input) { function showAutocomplete(category, input) {
autocompleteInput = input; autocompleteInput = input;
autocompleteCategory = category; autocompleteCategory = category;
autocompleteMode = 'single';
autocompleteIndex = -1; autocompleteIndex = -1;
filterAutocomplete(category, input.value); filterAutocomplete(category, input.value);
} }
@@ -519,16 +686,27 @@ function renderAutocomplete() {
dropdown.style.left = rect.left + 'px'; dropdown.style.left = rect.left + 'px';
dropdown.style.width = Math.max(rect.width, 400) + 'px'; dropdown.style.width = Math.max(rect.width, 400) + 'px';
// Use different select function based on mode (single vs multi) // Build autocomplete items based on mode
const selectFn = autocompleteCategory ? 'selectAutocompleteItem' : 'selectAutocompleteItemMulti'; dropdown.innerHTML = autocompleteFiltered.map((comp, idx) => {
let onmousedown;
dropdown.innerHTML = autocompleteFiltered.map((comp, idx) => ` if (autocompleteMode === 'section') {
<div class="autocomplete-item ${idx === autocompleteIndex ? 'selected' : ''}" onmousedown = `selectAutocompleteItemSection(${idx}, '${autocompleteCategory}')`;
onmousedown="${selectFn}(${idx})"> } else if (autocompleteMode === 'multi') {
<div class="font-mono text-sm">${escapeHtml(comp.lot_name)}</div> onmousedown = `selectAutocompleteItemMulti(${idx})`;
<div class="text-xs text-gray-500 truncate">${escapeHtml(comp.description || '')}</div> } else {
</div> // single mode
`).join(''); onmousedown = `selectAutocompleteItem(${idx})`;
}
return `
<div class="autocomplete-item ${idx === autocompleteIndex ? 'selected' : ''}"
onmousedown="${onmousedown}">
<div class="font-mono text-sm">${escapeHtml(comp.lot_name)}</div>
<div class="text-xs text-gray-500 truncate">${escapeHtml(comp.description || '')}</div>
</div>
`;
}).join('');
dropdown.classList.remove('hidden'); dropdown.classList.remove('hidden');
} }
@@ -575,12 +753,14 @@ function selectAutocompleteItem(index) {
hideAutocomplete(); hideAutocomplete();
renderTab(); renderTab();
updateCartUI(); updateCartUI();
triggerAutoSave();
} }
function hideAutocomplete() { function hideAutocomplete() {
document.getElementById('autocomplete-dropdown').classList.add('hidden'); document.getElementById('autocomplete-dropdown').classList.add('hidden');
autocompleteInput = null; autocompleteInput = null;
autocompleteCategory = null; autocompleteCategory = null;
autocompleteMode = null;
autocompleteIndex = -1; autocompleteIndex = -1;
} }
@@ -588,6 +768,7 @@ function hideAutocomplete() {
function showAutocompleteMulti(input) { function showAutocompleteMulti(input) {
autocompleteInput = input; autocompleteInput = input;
autocompleteCategory = null; autocompleteCategory = null;
autocompleteMode = 'multi';
autocompleteIndex = -1; autocompleteIndex = -1;
filterAutocompleteMulti(input.value); filterAutocompleteMulti(input.value);
} }
@@ -652,6 +833,102 @@ function selectAutocompleteItemMulti(index) {
hideAutocomplete(); hideAutocomplete();
renderTab(); renderTab();
updateCartUI(); updateCartUI();
triggerAutoSave();
}
// Autocomplete for sectioned tabs (like storage with RAID and Disks sections)
function showAutocompleteSection(sectionId, input) {
autocompleteInput = input;
autocompleteCategory = sectionId; // Store section ID
autocompleteMode = 'section';
autocompleteIndex = -1;
filterAutocompleteSection(sectionId, input.value, input);
}
function filterAutocompleteSection(sectionId, search, inputElement) {
const searchLower = search.toLowerCase();
// Get categories from input element's data attribute
const categoriesStr = inputElement && inputElement.dataset ? inputElement.dataset.categories : '';
if (!categoriesStr) {
autocompleteFiltered = [];
renderAutocomplete();
return;
}
const categoryList = categoriesStr.split(',').map(c => c.trim().toUpperCase());
// Get components for this section's categories
const sectionComponents = allComponents.filter(comp => {
const category = getComponentCategory(comp);
return categoryList.includes(category);
});
// Filter out already added items
const addedLots = new Set(cart.map(i => i.lot_name));
autocompleteFiltered = sectionComponents.filter(c => {
if (!c.current_price) return false;
if (addedLots.has(c.lot_name)) return false;
const text = (c.lot_name + ' ' + (c.description || '')).toLowerCase();
return text.includes(searchLower);
})
.sort((a, b) => {
// Sort by popularity_score desc, then by lot_name
const popDiff = (b.popularity_score || 0) - (a.popularity_score || 0);
if (popDiff !== 0) return popDiff;
return a.lot_name.localeCompare(b.lot_name);
});
renderAutocomplete();
}
function handleAutocompleteKeySection(event, sectionId) {
if (event.key === 'ArrowDown') {
event.preventDefault();
autocompleteIndex = Math.min(autocompleteIndex + 1, autocompleteFiltered.length - 1);
renderAutocomplete();
} else if (event.key === 'ArrowUp') {
event.preventDefault();
autocompleteIndex = Math.max(autocompleteIndex - 1, -1);
renderAutocomplete();
} else if (event.key === 'Enter') {
event.preventDefault();
if (autocompleteIndex >= 0 && autocompleteIndex < autocompleteFiltered.length) {
selectAutocompleteItemSection(autocompleteIndex, sectionId);
}
} else if (event.key === 'Escape') {
hideAutocomplete();
}
}
function selectAutocompleteItemSection(index, sectionId) {
const comp = autocompleteFiltered[index];
if (!comp) return;
const qtyInput = document.getElementById('new-qty-' + sectionId);
const qty = parseInt(qtyInput?.value) || 1;
cart.push({
lot_name: comp.lot_name,
quantity: qty,
unit_price: comp.current_price,
description: comp.description || '',
category: getComponentCategory(comp)
});
hideAutocomplete();
// Clear the input field
const input = document.getElementById('input-section-' + sectionId);
if (input) input.value = '';
// Reset quantity to 1
if (qtyInput) qtyInput.value = '1';
renderTab();
updateCartUI();
triggerAutoSave();
} }
function clearSingleSelect(category) { function clearSingleSelect(category) {
@@ -660,6 +937,7 @@ function clearSingleSelect(category) {
); );
renderTab(); renderTab();
updateCartUI(); updateCartUI();
triggerAutoSave();
} }
function updateSingleQuantity(category, value) { function updateSingleQuantity(category, value) {
@@ -672,6 +950,7 @@ function updateSingleQuantity(category, value) {
item.quantity = Math.max(1, qty); item.quantity = Math.max(1, qty);
renderTab(); renderTab();
updateCartUI(); updateCartUI();
triggerAutoSave();
} }
} }
@@ -682,6 +961,7 @@ function updateMultiQuantity(lotName, value) {
if (item) { if (item) {
item.quantity = Math.max(1, qty); item.quantity = Math.max(1, qty);
updateCartUI(); updateCartUI();
triggerAutoSave();
// Update total in the row // Update total in the row
const row = document.querySelector(`input[onchange*="${lotName}"]`)?.closest('tr'); const row = document.querySelector(`input[onchange*="${lotName}"]`)?.closest('tr');
if (row) { if (row) {
@@ -697,6 +977,7 @@ function removeFromCart(lotName) {
cart = cart.filter(i => i.lot_name !== lotName); cart = cart.filter(i => i.lot_name !== lotName);
renderTab(); renderTab();
updateCartUI(); updateCartUI();
triggerAutoSave();
} }
function updateCartUI() { function updateCartUI() {
@@ -712,16 +993,38 @@ function updateCartUI() {
return; return;
} }
// Sort cart items by category display order
const sortedCart = [...cart].sort((a, b) => {
const catA = (a.category || getCategoryFromLotName(a.lot_name)).toUpperCase();
const catB = (b.category || getCategoryFromLotName(b.lot_name)).toUpperCase();
const orderA = categoryOrderMap[catA] || 9999;
const orderB = categoryOrderMap[catB] || 9999;
return orderA - orderB;
});
const grouped = {}; const grouped = {};
cart.forEach(item => { sortedCart.forEach(item => {
const cat = item.category || getCategoryFromLotName(item.lot_name); const cat = item.category || getCategoryFromLotName(item.lot_name);
const tab = getTabForCategory(cat); const tab = getTabForCategory(cat);
if (!grouped[tab]) grouped[tab] = []; if (!grouped[tab]) grouped[tab] = [];
grouped[tab].push(item); grouped[tab].push(item);
}); });
// Sort tabs by minimum display order of their categories
const sortedTabs = Object.entries(grouped).sort((a, b) => {
const minOrderA = Math.min(...a[1].map(item => {
const cat = (item.category || getCategoryFromLotName(item.lot_name)).toUpperCase();
return categoryOrderMap[cat] || 9999;
}));
const minOrderB = Math.min(...b[1].map(item => {
const cat = (item.category || getCategoryFromLotName(item.lot_name)).toUpperCase();
return categoryOrderMap[cat] || 9999;
}));
return minOrderA - minOrderB;
});
let html = ''; let html = '';
for (const [tab, items] of Object.entries(grouped)) { for (const [tab, items] of sortedTabs) {
const tabLabel = TAB_CONFIG[tab]?.label || tab; const tabLabel = TAB_CONFIG[tab]?.label || tab;
html += `<div class="mb-2"><div class="text-xs font-medium text-gray-400 uppercase mb-1">${tabLabel}</div>`; html += `<div class="mb-2"><div class="text-xs font-medium text-gray-400 uppercase mb-1">${tabLabel}</div>`;
@@ -759,10 +1062,25 @@ function escapeHtml(text) {
return div.innerHTML; return div.innerHTML;
} }
async function saveConfig() { function triggerAutoSave() {
// Debounce autosave - wait 1 second after last change
if (autoSaveTimeout) {
clearTimeout(autoSaveTimeout);
}
autoSaveTimeout = setTimeout(() => {
saveConfig(false); // false = don't show notification
}, 1000);
}
async function saveConfig(showNotification = true) {
const token = localStorage.getItem('token'); const token = localStorage.getItem('token');
if (!token || !configUUID) return; if (!token || !configUUID) return;
// Get custom price if set
const customPriceInput = document.getElementById('custom-price-input');
const customPriceValue = parseFloat(customPriceInput.value);
const customPrice = customPriceValue > 0 ? customPriceValue : null;
try { try {
const resp = await fetch('/api/configs/' + configUUID, { const resp = await fetch('/api/configs/' + configUUID, {
method: 'PUT', method: 'PUT',
@@ -773,6 +1091,7 @@ async function saveConfig() {
body: JSON.stringify({ body: JSON.stringify({
name: configName, name: configName,
items: cart, items: cart,
custom_price: customPrice,
notes: '' notes: ''
}) })
}); });
@@ -783,13 +1102,19 @@ async function saveConfig() {
} }
if (!resp.ok) { if (!resp.ok) {
showToast('Ошибка сохранения', 'error'); if (showNotification) {
showToast('Ошибка сохранения', 'error');
}
return; return;
} }
showToast('Сохранено', 'success'); if (showNotification) {
showToast('Сохранено', 'success');
}
} catch(e) { } catch(e) {
showToast('Ошибка сохранения', 'error'); if (showNotification) {
showToast('Ошибка сохранения', 'error');
}
} }
} }
@@ -847,11 +1172,20 @@ function calculateCustomPrice() {
} }
// Build adjusted prices table // Build adjusted prices table
// Sort cart items by category display order
const sortedCart = [...cart].sort((a, b) => {
const catA = (a.category || getCategoryFromLotName(a.lot_name)).toUpperCase();
const catB = (b.category || getCategoryFromLotName(b.lot_name)).toUpperCase();
const orderA = categoryOrderMap[catA] || 9999;
const orderB = categoryOrderMap[catB] || 9999;
return orderA - orderB;
});
let html = ''; let html = '';
let totalOriginal = 0; let totalOriginal = 0;
let totalNew = 0; let totalNew = 0;
cart.forEach(item => { sortedCart.forEach(item => {
const originalPrice = item.unit_price; const originalPrice = item.unit_price;
const newPrice = originalPrice * coefficient; const newPrice = originalPrice * coefficient;
const itemOriginalTotal = originalPrice * item.quantity; const itemOriginalTotal = originalPrice * item.quantity;
@@ -882,6 +1216,7 @@ function clearCustomPrice() {
document.getElementById('custom-price-input').value = ''; document.getElementById('custom-price-input').value = '';
document.getElementById('adjusted-prices').classList.add('hidden'); document.getElementById('adjusted-prices').classList.add('hidden');
document.getElementById('discount-info').classList.add('hidden'); document.getElementById('discount-info').classList.add('hidden');
triggerAutoSave();
} }
async function exportCSVWithCustomPrice() { async function exportCSVWithCustomPrice() {