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:
co-authored by
Claude Opus 4.5
parent
44ccb01203
commit
a93644131c
@@ -6,9 +6,9 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/mchus/quoteforge/internal/middleware"
|
||||
"github.com/mchus/quoteforge/internal/models"
|
||||
"github.com/mchus/quoteforge/internal/services"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/middleware"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/services"
|
||||
)
|
||||
|
||||
type ExportHandler struct {
|
||||
@@ -59,26 +59,6 @@ func (h *ExportHandler) ExportCSV(c *gin.Context) {
|
||||
c.Data(http.StatusOK, "text/csv; charset=utf-8", csvData)
|
||||
}
|
||||
|
||||
func (h *ExportHandler) ExportXLSX(c *gin.Context) {
|
||||
var req ExportRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
data := h.buildExportData(&req)
|
||||
|
||||
xlsxData, err := h.exportService.ToXLSX(data)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
filename := fmt.Sprintf("%s_%s.xlsx", req.Name, time.Now().Format("20060102"))
|
||||
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename))
|
||||
c.Data(http.StatusOK, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", xlsxData)
|
||||
}
|
||||
|
||||
func (h *ExportHandler) buildExportData(req *ExportRequest) *services.ExportData {
|
||||
items := make([]services.ExportItem, len(req.Items))
|
||||
var total float64
|
||||
@@ -126,29 +106,6 @@ func (h *ExportHandler) ExportConfigCSV(c *gin.Context) {
|
||||
c.Data(http.StatusOK, "text/csv; charset=utf-8", csvData)
|
||||
}
|
||||
|
||||
func (h *ExportHandler) ExportConfigXLSX(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 := h.configToExportData(config)
|
||||
|
||||
xlsxData, err := h.exportService.ToXLSX(data)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
filename := fmt.Sprintf("%s_%s.xlsx", config.Name, config.CreatedAt.Format("20060102"))
|
||||
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename))
|
||||
c.Data(http.StatusOK, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", xlsxData)
|
||||
}
|
||||
|
||||
func (h *ExportHandler) configToExportData(config *models.Configuration) *services.ExportData {
|
||||
items := make([]services.ExportItem, len(config.Items))
|
||||
var total float64
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/services"
|
||||
)
|
||||
|
||||
type WebHandler struct {
|
||||
templates map[string]*template.Template
|
||||
componentService *services.ComponentService
|
||||
}
|
||||
|
||||
func NewWebHandler(templatesPath string, componentService *services.ComponentService) (*WebHandler, error) {
|
||||
funcMap := template.FuncMap{
|
||||
"sub": func(a, b int) int { return a - b },
|
||||
"add": func(a, b int) int { return a + b },
|
||||
"mul": func(a, b int) int { return a * b },
|
||||
"div": func(a, b int) int {
|
||||
if b == 0 {
|
||||
return 0
|
||||
}
|
||||
return (a + b - 1) / b
|
||||
},
|
||||
"deref": func(f *float64) float64 {
|
||||
if f == nil {
|
||||
return 0
|
||||
}
|
||||
return *f
|
||||
},
|
||||
"jsesc": func(s string) string {
|
||||
// Escape string for safe use in JavaScript
|
||||
result := ""
|
||||
for _, r := range s {
|
||||
switch r {
|
||||
case '\\':
|
||||
result += "\\\\"
|
||||
case '\'':
|
||||
result += "\\'"
|
||||
case '"':
|
||||
result += "\\\""
|
||||
case '\n':
|
||||
result += "\\n"
|
||||
case '\r':
|
||||
result += "\\r"
|
||||
case '\t':
|
||||
result += "\\t"
|
||||
default:
|
||||
result += string(r)
|
||||
}
|
||||
}
|
||||
return result
|
||||
},
|
||||
}
|
||||
|
||||
templates := make(map[string]*template.Template)
|
||||
basePath := filepath.Join(templatesPath, "base.html")
|
||||
|
||||
// Load each page template with base
|
||||
simplePages := []string{"login.html", "configs.html", "admin_pricing.html"}
|
||||
for _, page := range simplePages {
|
||||
pagePath := filepath.Join(templatesPath, page)
|
||||
tmpl, err := template.New("").Funcs(funcMap).ParseFiles(basePath, pagePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
templates[page] = tmpl
|
||||
}
|
||||
|
||||
// Index page needs components_list.html as well
|
||||
indexPath := filepath.Join(templatesPath, "index.html")
|
||||
componentsListPath := filepath.Join(templatesPath, "components_list.html")
|
||||
indexTmpl, err := template.New("").Funcs(funcMap).ParseFiles(basePath, indexPath, componentsListPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
templates["index.html"] = indexTmpl
|
||||
|
||||
// Load partial templates (no base needed)
|
||||
partials := []string{"components_list.html"}
|
||||
for _, partial := range partials {
|
||||
partialPath := filepath.Join(templatesPath, partial)
|
||||
tmpl, err := template.New("").Funcs(funcMap).ParseFiles(partialPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
templates[partial] = tmpl
|
||||
}
|
||||
|
||||
return &WebHandler{
|
||||
templates: templates,
|
||||
componentService: componentService,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (h *WebHandler) render(c *gin.Context, name string, data gin.H) {
|
||||
c.Header("Content-Type", "text/html; charset=utf-8")
|
||||
tmpl, ok := h.templates[name]
|
||||
if !ok {
|
||||
c.String(500, "Template not found: %s", name)
|
||||
return
|
||||
}
|
||||
// Execute the page template which will use base
|
||||
if err := tmpl.ExecuteTemplate(c.Writer, name, data); err != nil {
|
||||
c.String(500, "Template error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *WebHandler) Index(c *gin.Context) {
|
||||
// Redirect to configs page - configurator is accessed via /configurator?uuid=...
|
||||
c.Redirect(302, "/configs")
|
||||
}
|
||||
|
||||
func (h *WebHandler) Configurator(c *gin.Context) {
|
||||
categories, _ := h.componentService.GetCategories()
|
||||
uuid := c.Query("uuid")
|
||||
|
||||
filter := repository.ComponentFilter{}
|
||||
result, err := h.componentService.List(filter, 1, 20)
|
||||
|
||||
data := gin.H{
|
||||
"ActivePage": "configurator",
|
||||
"Categories": categories,
|
||||
"Components": []interface{}{},
|
||||
"Total": int64(0),
|
||||
"Page": 1,
|
||||
"PerPage": 20,
|
||||
"ConfigUUID": uuid,
|
||||
}
|
||||
|
||||
if err == nil && result != nil {
|
||||
data["Components"] = result.Components
|
||||
data["Total"] = result.Total
|
||||
data["Page"] = result.Page
|
||||
data["PerPage"] = result.PerPage
|
||||
}
|
||||
|
||||
h.render(c, "index.html", data)
|
||||
}
|
||||
|
||||
func (h *WebHandler) Login(c *gin.Context) {
|
||||
h.render(c, "login.html", nil)
|
||||
}
|
||||
|
||||
func (h *WebHandler) Configs(c *gin.Context) {
|
||||
h.render(c, "configs.html", gin.H{"ActivePage": "configs"})
|
||||
}
|
||||
|
||||
func (h *WebHandler) AdminPricing(c *gin.Context) {
|
||||
h.render(c, "admin_pricing.html", gin.H{"ActivePage": "admin"})
|
||||
}
|
||||
|
||||
// Partials for htmx
|
||||
|
||||
func (h *WebHandler) ComponentsPartial(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
|
||||
filter := repository.ComponentFilter{
|
||||
Category: c.Query("category"),
|
||||
Search: c.Query("search"),
|
||||
}
|
||||
|
||||
data := gin.H{
|
||||
"Components": []interface{}{},
|
||||
"Total": int64(0),
|
||||
"Page": page,
|
||||
"PerPage": 20,
|
||||
}
|
||||
|
||||
result, err := h.componentService.List(filter, page, 20)
|
||||
if err == nil && result != nil {
|
||||
data["Components"] = result.Components
|
||||
data["Total"] = result.Total
|
||||
data["Page"] = result.Page
|
||||
data["PerPage"] = result.PerPage
|
||||
}
|
||||
|
||||
c.Header("Content-Type", "text/html; charset=utf-8")
|
||||
if tmpl, ok := h.templates["components_list.html"]; ok {
|
||||
tmpl.ExecuteTemplate(c.Writer, "components_list.html", data)
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user