Добавлены сортировка по категориям, секции 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:
27
apply_migration.sh
Executable file
27
apply_migration.sh
Executable 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
160
cmd/importer/main.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
// }
|
||||||
|
|||||||
@@ -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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
// }
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
11
migrations/001_add_lot_category.sql
Normal file
11
migrations/001_add_lot_category.sql
Normal 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;
|
||||||
2
migrations/002_add_custom_price.sql
Normal file
2
migrations/002_add_custom_price.sql
Normal 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';
|
||||||
@@ -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}}
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
Reference in New Issue
Block a user