Redesign configurator UI with tabs and remove Excel export

- Add tab-based configurator (Base, Storage, PCI, Power, Accessories, Other)
- Base tab: single-select with autocomplete for MB, CPU, MEM
- Other tabs: multi-select with autocomplete and quantity input
- Table view with LOT, Description, Price, Quantity, Total columns
- Add configuration list page with create modal (opportunity number)
- Remove Excel export functionality and excelize dependency
- Increase component list limit from 100 to 5000
- Add web templates (base, index, configs, login, admin_pricing)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Mikhail Chusavitin
2026-01-26 15:57:15 +03:00
parent 44ccb01203
commit a93644131c
14 changed files with 2041 additions and 201 deletions

View File

@@ -3,8 +3,8 @@ package services
import (
"strings"
"github.com/mchus/quoteforge/internal/models"
"github.com/mchus/quoteforge/internal/repository"
"git.mchus.pro/mchus/quoteforge/internal/models"
"git.mchus.pro/mchus/quoteforge/internal/repository"
)
type ComponentService struct {
@@ -25,19 +25,16 @@ func NewComponentService(
}
}
// ParsePartNumber extracts category, vendor, model from lot_name
// "CPU_AMD_9654" → category="CPU", vendor="AMD", model="9654"
// "MB_INTEL_4.Sapphire_2S_32xDDR5" → category="MB", vendor="INTEL", model="4.Sapphire_2S_32xDDR5"
func ParsePartNumber(lotName string) (category, vendor, model string) {
parts := strings.SplitN(lotName, "_", 3)
// ParsePartNumber extracts category and model from lot_name
// "CPU_AMD_9654" → category="CPU", model="AMD_9654"
// "MB_INTEL_4.Sapphire_2S_32xDDR5" → category="MB", model="INTEL_4.Sapphire_2S_32xDDR5"
func ParsePartNumber(lotName string) (category, model string) {
parts := strings.SplitN(lotName, "_", 2)
if len(parts) >= 1 {
category = parts[0]
}
if len(parts) >= 2 {
vendor = parts[1]
}
if len(parts) >= 3 {
model = parts[2]
model = parts[1]
}
return
}
@@ -50,25 +47,27 @@ type ComponentListResult struct {
}
type ComponentView struct {
LotName string `json:"lot_name"`
Description string `json:"description"`
Category string `json:"category"`
CategoryName string `json:"category_name"`
Vendor string `json:"vendor"`
Model string `json:"model"`
CurrentPrice *float64 `json:"current_price"`
PriceFreshness models.PriceFreshness `json:"price_freshness"`
PopularityScore float64 `json:"popularity_score"`
Specs models.Specs `json:"specs,omitempty"`
LotName string `json:"lot_name"`
Description string `json:"description"`
Category string `json:"category"`
CategoryName string `json:"category_name"`
Model string `json:"model"`
CurrentPrice *float64 `json:"current_price"`
PriceFreshness models.PriceFreshness `json:"price_freshness"`
PopularityScore float64 `json:"popularity_score"`
Specs models.Specs `json:"specs,omitempty"`
}
func (s *ComponentService) List(filter repository.ComponentFilter, page, perPage int) (*ComponentListResult, error) {
if page < 1 {
page = 1
}
if perPage < 1 || perPage > 100 {
if perPage < 1 {
perPage = 20
}
if perPage > 5000 {
perPage = 5000
}
offset := (page - 1) * perPage
components, total, err := s.componentRepo.List(filter, offset, perPage)
@@ -80,7 +79,6 @@ func (s *ComponentService) List(filter repository.ComponentFilter, page, perPage
for i, c := range components {
view := ComponentView{
LotName: c.LotName,
Vendor: c.Vendor,
Model: c.Model,
CurrentPrice: c.CurrentPrice,
PriceFreshness: c.GetPriceFreshness(30, 60, 90, 3),
@@ -118,7 +116,6 @@ func (s *ComponentService) GetByLotName(lotName string) (*ComponentView, error)
view := &ComponentView{
LotName: c.LotName,
Vendor: c.Vendor,
Model: c.Model,
CurrentPrice: c.CurrentPrice,
PriceFreshness: c.GetPriceFreshness(30, 60, 90, 3),
@@ -141,10 +138,6 @@ func (s *ComponentService) GetCategories() ([]models.Category, error) {
return s.categoryRepo.GetAll()
}
func (s *ComponentService) GetVendors(category string) ([]string, error) {
return s.componentRepo.GetVendors(category)
}
// ImportFromLot creates metadata entries for lots that don't have them
func (s *ComponentService) ImportFromLot() (int, error) {
lots, err := s.componentRepo.GetLotsWithoutMetadata()
@@ -164,11 +157,10 @@ func (s *ComponentService) ImportFromLot() (int, error) {
imported := 0
for _, lot := range lots {
category, vendor, model := ParsePartNumber(lot.LotName)
category, model := ParsePartNumber(lot.LotName)
metadata := &models.LotMetadata{
LotName: lot.LotName,
Vendor: vendor,
Model: model,
Specs: make(models.Specs),
}

View File

@@ -6,9 +6,8 @@ import (
"fmt"
"time"
"github.com/mchus/quoteforge/internal/config"
"github.com/mchus/quoteforge/internal/models"
"github.com/xuri/excelize/v2"
"git.mchus.pro/mchus/quoteforge/internal/config"
"git.mchus.pro/mchus/quoteforge/internal/models"
)
type ExportService struct {
@@ -20,11 +19,11 @@ func NewExportService(cfg config.ExportConfig) *ExportService {
}
type ExportData struct {
Name string
Items []ExportItem
Total float64
Notes string
CreatedAt time.Time
Name string
Items []ExportItem
Total float64
Notes string
CreatedAt time.Time
}
type ExportItem struct {
@@ -70,86 +69,6 @@ func (s *ExportService) ToCSV(data *ExportData) ([]byte, error) {
return buf.Bytes(), w.Error()
}
func (s *ExportService) ToXLSX(data *ExportData) ([]byte, error) {
f := excelize.NewFile()
sheet := "Конфигурация"
f.SetSheetName("Sheet1", sheet)
// Styles
headerStyle, _ := f.NewStyle(&excelize.Style{
Font: &excelize.Font{Bold: true, Size: 12, Color: "#FFFFFF"},
Fill: excelize.Fill{Type: "pattern", Color: []string{"#4472C4"}, Pattern: 1},
Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center"},
Border: []excelize.Border{
{Type: "left", Color: "#000000", Style: 1},
{Type: "top", Color: "#000000", Style: 1},
{Type: "bottom", Color: "#000000", Style: 1},
{Type: "right", Color: "#000000", Style: 1},
},
})
totalStyle, _ := f.NewStyle(&excelize.Style{
Font: &excelize.Font{Bold: true, Size: 12},
Fill: excelize.Fill{Type: "pattern", Color: []string{"#E2EFDA"}, Pattern: 1},
})
priceStyle, _ := f.NewStyle(&excelize.Style{
NumFmt: 4, // #,##0.00
})
// Title
f.SetCellValue(sheet, "A1", s.config.CompanyName)
f.SetCellValue(sheet, "A2", "Коммерческое предложение: "+data.Name)
f.SetCellValue(sheet, "A3", "Дата: "+data.CreatedAt.Format("02.01.2006"))
// Headers
headers := []string{"Артикул", "Описание", "Категория", "Кол-во", "Цена", "Сумма"}
for i, h := range headers {
cell := fmt.Sprintf("%c5", 'A'+i)
f.SetCellValue(sheet, cell, h)
f.SetCellStyle(sheet, cell, cell, headerStyle)
}
// Data rows
row := 6
for _, item := range data.Items {
f.SetCellValue(sheet, fmt.Sprintf("A%d", row), item.LotName)
f.SetCellValue(sheet, fmt.Sprintf("B%d", row), item.Description)
f.SetCellValue(sheet, fmt.Sprintf("C%d", row), item.Category)
f.SetCellValue(sheet, fmt.Sprintf("D%d", row), item.Quantity)
f.SetCellValue(sheet, fmt.Sprintf("E%d", row), item.UnitPrice)
f.SetCellValue(sheet, fmt.Sprintf("F%d", row), item.TotalPrice)
f.SetCellStyle(sheet, fmt.Sprintf("E%d", row), fmt.Sprintf("F%d", row), priceStyle)
row++
}
// Total row
f.SetCellValue(sheet, fmt.Sprintf("E%d", row), "ИТОГО:")
f.SetCellValue(sheet, fmt.Sprintf("F%d", row), data.Total)
f.SetCellStyle(sheet, fmt.Sprintf("E%d", row), fmt.Sprintf("F%d", row), totalStyle)
// Notes
if data.Notes != "" {
row += 2
f.SetCellValue(sheet, fmt.Sprintf("A%d", row), "Примечания: "+data.Notes)
}
// Column widths
f.SetColWidth(sheet, "A", "A", 25)
f.SetColWidth(sheet, "B", "B", 50)
f.SetColWidth(sheet, "C", "C", 15)
f.SetColWidth(sheet, "D", "D", 10)
f.SetColWidth(sheet, "E", "E", 15)
f.SetColWidth(sheet, "F", "F", 15)
var buf bytes.Buffer
if err := f.Write(&buf); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
func (s *ExportService) ConfigToExportData(config *models.Configuration) *ExportData {
items := make([]ExportItem, len(config.Items))
var total float64