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

@@ -11,14 +11,15 @@ import (
"time" "time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/mchus/quoteforge/internal/config" "git.mchus.pro/mchus/quoteforge/internal/config"
"github.com/mchus/quoteforge/internal/handlers" "git.mchus.pro/mchus/quoteforge/internal/handlers"
"github.com/mchus/quoteforge/internal/middleware" "git.mchus.pro/mchus/quoteforge/internal/middleware"
"github.com/mchus/quoteforge/internal/models" "git.mchus.pro/mchus/quoteforge/internal/models"
"github.com/mchus/quoteforge/internal/repository" "git.mchus.pro/mchus/quoteforge/internal/repository"
"github.com/mchus/quoteforge/internal/services" "git.mchus.pro/mchus/quoteforge/internal/services"
"github.com/mchus/quoteforge/internal/services/alerts" "git.mchus.pro/mchus/quoteforge/internal/services/alerts"
"github.com/mchus/quoteforge/internal/services/pricing" "git.mchus.pro/mchus/quoteforge/internal/services/pricing"
"golang.org/x/crypto/bcrypt"
"gorm.io/driver/mysql" "gorm.io/driver/mysql"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/logger" "gorm.io/gorm/logger"
@@ -59,11 +60,21 @@ func main() {
slog.Error("seeding categories failed", "error", err) slog.Error("seeding categories failed", "error", err)
os.Exit(1) os.Exit(1)
} }
// Create default admin user (admin / admin123)
adminHash, _ := bcrypt.GenerateFromPassword([]byte("admin123"), bcrypt.DefaultCost)
if err := models.SeedAdminUser(db, string(adminHash)); err != nil {
slog.Error("seeding admin user failed", "error", err)
os.Exit(1)
}
slog.Info("migrations completed") slog.Info("migrations completed")
} }
gin.SetMode(cfg.Server.Mode) gin.SetMode(cfg.Server.Mode)
router := setupRouter(db, cfg) router, err := setupRouter(db, cfg)
if err != nil {
slog.Error("failed to setup router", "error", err)
os.Exit(1)
}
srv := &http.Server{ srv := &http.Server{
Addr: cfg.Address(), Addr: cfg.Address(),
@@ -143,7 +154,7 @@ func setupDatabase(cfg config.DatabaseConfig) (*gorm.DB, error) {
return db, nil return db, nil
} }
func setupRouter(db *gorm.DB, cfg *config.Config) *gin.Engine { func setupRouter(db *gorm.DB, cfg *config.Config) (*gin.Engine, error) {
// Repositories // Repositories
userRepo := repository.NewUserRepository(db) userRepo := repository.NewUserRepository(db)
componentRepo := repository.NewComponentRepository(db) componentRepo := repository.NewComponentRepository(db)
@@ -168,7 +179,13 @@ func setupRouter(db *gorm.DB, cfg *config.Config) *gin.Engine {
quoteHandler := handlers.NewQuoteHandler(quoteService) quoteHandler := handlers.NewQuoteHandler(quoteService)
configHandler := handlers.NewConfigurationHandler(configService, exportService) configHandler := handlers.NewConfigurationHandler(configService, exportService)
exportHandler := handlers.NewExportHandler(exportService, configService, componentService) exportHandler := handlers.NewExportHandler(exportService, configService, componentService)
pricingHandler := handlers.NewPricingHandler(pricingService, alertService, componentRepo, statsRepo) pricingHandler := handlers.NewPricingHandler(db, pricingService, alertService, componentRepo, priceRepo, statsRepo)
// Web handler (templates)
webHandler, err := handlers.NewWebHandler("web/templates", componentService)
if err != nil {
return nil, err
}
// Router // Router
router := gin.New() router := gin.New()
@@ -176,6 +193,9 @@ func setupRouter(db *gorm.DB, cfg *config.Config) *gin.Engine {
router.Use(requestLogger()) router.Use(requestLogger())
router.Use(middleware.CORS()) router.Use(middleware.CORS())
// Static files
router.Static("/static", "web/static")
// Health check // Health check
router.GET("/health", func(c *gin.Context) { router.GET("/health", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
@@ -184,6 +204,47 @@ func setupRouter(db *gorm.DB, cfg *config.Config) *gin.Engine {
}) })
}) })
// DB status endpoint
router.GET("/api/db-status", func(c *gin.Context) {
var lotCount, lotLogCount, metadataCount int64
var dbOK bool = true
var dbError string
sqlDB, err := db.DB()
if err != nil {
dbOK = false
dbError = err.Error()
} else if err := sqlDB.Ping(); err != nil {
dbOK = false
dbError = err.Error()
}
db.Table("lot").Count(&lotCount)
db.Table("lot_log").Count(&lotLogCount)
db.Table("qt_lot_metadata").Count(&metadataCount)
c.JSON(http.StatusOK, gin.H{
"connected": dbOK,
"error": dbError,
"lot_count": lotCount,
"lot_log_count": lotLogCount,
"metadata_count": metadataCount,
})
})
// Web pages
router.GET("/", webHandler.Index)
router.GET("/login", webHandler.Login)
router.GET("/configs", webHandler.Configs)
router.GET("/configurator", webHandler.Configurator)
router.GET("/admin/pricing", webHandler.AdminPricing)
// htmx partials
partials := router.Group("/partials")
{
partials.GET("/components", webHandler.ComponentsPartial)
}
// API routes // API routes
api := router.Group("/api") api := router.Group("/api")
{ {
@@ -209,7 +270,6 @@ func setupRouter(db *gorm.DB, cfg *config.Config) *gin.Engine {
// Categories (public) // Categories (public)
api.GET("/categories", componentHandler.GetCategories) api.GET("/categories", componentHandler.GetCategories)
api.GET("/vendors", componentHandler.GetVendors)
// Quote (public, for anonymous quote building) // Quote (public, for anonymous quote building)
quote := api.Group("/quote") quote := api.Group("/quote")
@@ -222,7 +282,6 @@ func setupRouter(db *gorm.DB, cfg *config.Config) *gin.Engine {
export := api.Group("/export") export := api.Group("/export")
{ {
export.POST("/csv", exportHandler.ExportCSV) export.POST("/csv", exportHandler.ExportCSV)
export.POST("/xlsx", exportHandler.ExportXLSX)
} }
// Configurations (requires auth) // Configurations (requires auth)
@@ -237,7 +296,6 @@ func setupRouter(db *gorm.DB, cfg *config.Config) *gin.Engine {
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.GET("/:uuid/xlsx", exportHandler.ExportConfigXLSX)
configs.POST("/import", configHandler.ImportJSON) configs.POST("/import", configHandler.ImportJSON)
} }
} }
@@ -254,6 +312,7 @@ func setupRouter(db *gorm.DB, cfg *config.Config) *gin.Engine {
pricingAdmin.GET("/components", pricingHandler.ListComponents) pricingAdmin.GET("/components", pricingHandler.ListComponents)
pricingAdmin.GET("/components/:lot_name", pricingHandler.GetComponentPricing) pricingAdmin.GET("/components/:lot_name", pricingHandler.GetComponentPricing)
pricingAdmin.POST("/update", pricingHandler.UpdatePrice) pricingAdmin.POST("/update", pricingHandler.UpdatePrice)
pricingAdmin.POST("/preview", pricingHandler.PreviewPrice)
pricingAdmin.POST("/recalculate-all", pricingHandler.RecalculateAll) pricingAdmin.POST("/recalculate-all", pricingHandler.RecalculateAll)
pricingAdmin.GET("/alerts", pricingHandler.ListAlerts) pricingAdmin.GET("/alerts", pricingHandler.ListAlerts)
@@ -263,7 +322,7 @@ func setupRouter(db *gorm.DB, cfg *config.Config) *gin.Engine {
} }
} }
return router return router, nil
} }
func requestLogger() gin.HandlerFunc { func requestLogger() gin.HandlerFunc {

9
go.mod
View File

@@ -1,4 +1,4 @@
module github.com/mchus/quoteforge module git.mchus.pro/mchus/quoteforge
go 1.24.0 go 1.24.0
@@ -6,7 +6,6 @@ require (
github.com/gin-gonic/gin v1.9.1 github.com/gin-gonic/gin v1.9.1
github.com/golang-jwt/jwt/v5 v5.3.0 github.com/golang-jwt/jwt/v5 v5.3.0
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/xuri/excelize/v2 v2.10.0
golang.org/x/crypto v0.43.0 golang.org/x/crypto v0.43.0
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
gorm.io/driver/mysql v1.5.2 gorm.io/driver/mysql v1.5.2
@@ -32,13 +31,9 @@ require (
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.0.8 // indirect github.com/pelletier/go-toml/v2 v2.0.8 // indirect
github.com/richardlehane/mscfb v1.0.4 // indirect github.com/stretchr/testify v1.11.1 // indirect
github.com/richardlehane/msoleps v1.0.4 // indirect
github.com/tiendc/go-deepcopy v1.7.1 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.11 // indirect github.com/ugorji/go/codec v1.2.11 // indirect
github.com/xuri/efp v0.0.1 // indirect
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect
golang.org/x/arch v0.3.0 // indirect golang.org/x/arch v0.3.0 // indirect
golang.org/x/net v0.46.0 // indirect golang.org/x/net v0.46.0 // indirect
golang.org/x/sys v0.37.0 // indirect golang.org/x/sys v0.37.0 // indirect

15
go.sum
View File

@@ -56,11 +56,6 @@ github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZ
github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4= github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/richardlehane/mscfb v1.0.4 h1:WULscsljNPConisD5hR0+OyZjwK46Pfyr6mPu5ZawpM=
github.com/richardlehane/mscfb v1.0.4/go.mod h1:YzVpcZg9czvAuhk9T+a3avCpcFPMUWm7gK3DypaEsUk=
github.com/richardlehane/msoleps v1.0.1/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
github.com/richardlehane/msoleps v1.0.4 h1:WuESlvhX3gH2IHcd8UqyCuFY5yiq/GR/yqaSM/9/g00=
github.com/richardlehane/msoleps v1.0.4/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
@@ -73,25 +68,15 @@ github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tiendc/go-deepcopy v1.7.1 h1:LnubftI6nYaaMOcaz0LphzwraqN8jiWTwm416sitff4=
github.com/tiendc/go-deepcopy v1.7.1/go.mod h1:4bKjNC2r7boYOkD2IOuZpYjmlDdzjbpTRyCx+goBCJQ=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8=
github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI=
github.com/xuri/excelize/v2 v2.10.0 h1:8aKsP7JD39iKLc6dH5Tw3dgV3sPRh8uRVXu/fMstfW4=
github.com/xuri/excelize/v2 v2.10.0/go.mod h1:SC5TzhQkaOsTWpANfm+7bJCldzcnU/jrhqkTi/iBHBU=
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE=
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k= golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ=
golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs=
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=

View File

@@ -6,9 +6,9 @@ import (
"time" "time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/mchus/quoteforge/internal/middleware" "git.mchus.pro/mchus/quoteforge/internal/middleware"
"github.com/mchus/quoteforge/internal/models" "git.mchus.pro/mchus/quoteforge/internal/models"
"github.com/mchus/quoteforge/internal/services" "git.mchus.pro/mchus/quoteforge/internal/services"
) )
type ExportHandler struct { type ExportHandler struct {
@@ -59,26 +59,6 @@ func (h *ExportHandler) ExportCSV(c *gin.Context) {
c.Data(http.StatusOK, "text/csv; charset=utf-8", csvData) 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 { func (h *ExportHandler) buildExportData(req *ExportRequest) *services.ExportData {
items := make([]services.ExportItem, len(req.Items)) items := make([]services.ExportItem, len(req.Items))
var total float64 var total float64
@@ -126,29 +106,6 @@ func (h *ExportHandler) ExportConfigCSV(c *gin.Context) {
c.Data(http.StatusOK, "text/csv; charset=utf-8", csvData) 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 { func (h *ExportHandler) configToExportData(config *models.Configuration) *services.ExportData {
items := make([]services.ExportItem, len(config.Items)) items := make([]services.ExportItem, len(config.Items))
var total float64 var total float64

186
internal/handlers/web.go Normal file
View File

@@ -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)
}
}

View File

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

View File

@@ -6,9 +6,8 @@ import (
"fmt" "fmt"
"time" "time"
"github.com/mchus/quoteforge/internal/config" "git.mchus.pro/mchus/quoteforge/internal/config"
"github.com/mchus/quoteforge/internal/models" "git.mchus.pro/mchus/quoteforge/internal/models"
"github.com/xuri/excelize/v2"
) )
type ExportService struct { type ExportService struct {
@@ -20,11 +19,11 @@ func NewExportService(cfg config.ExportConfig) *ExportService {
} }
type ExportData struct { type ExportData struct {
Name string Name string
Items []ExportItem Items []ExportItem
Total float64 Total float64
Notes string Notes string
CreatedAt time.Time CreatedAt time.Time
} }
type ExportItem struct { type ExportItem struct {
@@ -70,86 +69,6 @@ func (s *ExportService) ToCSV(data *ExportData) ([]byte, error) {
return buf.Bytes(), w.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 { func (s *ExportService) ConfigToExportData(config *models.Configuration) *ExportData {
items := make([]ExportItem, len(config.Items)) items := make([]ExportItem, len(config.Items))
var total float64 var total float64

9
web/static/app.css Normal file
View File

@@ -0,0 +1,9 @@
/* QuoteForge custom styles */
/* Tailwind is loaded via CDN, this file is for any custom overrides */
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}

View File

@@ -0,0 +1,549 @@
{{define "title"}}Цены - QuoteForge{{end}}
{{define "content"}}
<div class="space-y-4">
<h1 class="text-2xl font-bold">Управление ценами</h1>
<div class="bg-white rounded-lg shadow p-4">
<div class="flex justify-between items-center border-b pb-4 mb-4">
<div class="flex gap-4">
<button onclick="loadTab('alerts')" id="btn-alerts" class="text-blue-600 font-medium">Алерты</button>
<button onclick="loadTab('components')" id="btn-components" class="text-gray-600">Компоненты</button>
</div>
<button onclick="recalculateAll()" id="btn-recalc" class="px-3 py-1 bg-green-600 text-white text-sm rounded hover:bg-green-700">
Пересчитать цены
</button>
</div>
<!-- Progress bar -->
<div id="progress-container" class="mb-4 p-4 bg-blue-50 rounded-lg border border-blue-200" style="display:none;">
<div class="flex justify-between text-sm text-gray-700 mb-2">
<span id="progress-text" class="font-medium">Пересчёт цен...</span>
<span id="progress-percent" class="font-bold">0%</span>
</div>
<div class="w-full bg-gray-200 rounded-full h-4">
<div id="progress-bar" class="bg-blue-600 h-4 rounded-full transition-all duration-300" style="width: 0%"></div>
</div>
<div class="text-sm text-gray-600 mt-2">
<span id="progress-stats"></span>
</div>
</div>
<!-- Search (only for components) -->
<div id="search-bar" class="mb-4 hidden">
<input type="text" id="search-input" placeholder="Поиск по артикулу..."
class="w-full px-3 py-2 border rounded"
onkeyup="debounceSearch()">
</div>
<div id="tab-content">
<div class="text-center py-8 text-gray-500">Загрузка...</div>
</div>
<!-- Pagination -->
<div id="pagination" class="flex justify-between items-center mt-4 pt-4 border-t hidden">
<span id="page-info" class="text-sm text-gray-600"></span>
<div class="flex gap-2">
<button onclick="prevPage()" id="btn-prev" class="px-3 py-1 border rounded text-sm disabled:opacity-50">Назад</button>
<button onclick="nextPage()" id="btn-next" class="px-3 py-1 border rounded text-sm disabled:opacity-50">Вперед</button>
</div>
</div>
</div>
</div>
<!-- Price Settings Modal -->
<div id="price-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">
<div class="flex justify-between items-center p-4 border-b">
<h3 class="text-lg font-semibold">Настройка цены</h3>
<button onclick="closeModal()" class="text-gray-500 hover:text-gray-700">&times;</button>
</div>
<div class="p-4 space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Артикул</label>
<input type="text" id="modal-lot-name" readonly class="w-full px-3 py-2 border rounded bg-gray-100">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Метод расчёта</label>
<select id="modal-method" class="w-full px-3 py-2 border rounded">
<option value="median">Медиана</option>
<option value="average">Среднее</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Период расчёта</label>
<select id="modal-period" class="w-full px-3 py-2 border rounded">
<option value="7">1 неделя</option>
<option value="30">1 месяц</option>
<option value="90">1 квартал</option>
<option value="365">1 год</option>
<option value="0">Всё время</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Коэффициент корректировки (%)</label>
<input type="number" id="modal-coefficient" step="1" class="w-full px-3 py-2 border rounded" placeholder="0">
<p class="text-xs text-gray-500 mt-1">Например: 30 для +30%, -10 для -10%</p>
</div>
<div class="border-t pt-4">
<label class="flex items-center mb-2">
<input type="checkbox" id="modal-manual-enabled" class="mr-2" onchange="toggleManualPrice()">
<span class="text-sm font-medium text-gray-700">Установить цену вручную</span>
</label>
<input type="number" id="modal-manual-price" step="0.01" class="w-full px-3 py-2 border rounded" placeholder="Цена USD" disabled>
<p class="text-xs text-gray-500 mt-1">Ручная цена сохраняется при пересчёте</p>
</div>
<div class="bg-gray-50 p-3 rounded space-y-2">
<div class="text-sm font-medium text-gray-700 mb-2">Расчёт цены</div>
<div class="grid grid-cols-2 gap-2 text-sm">
<div class="text-gray-600">Медиана (всё время):</div>
<div id="modal-median-all" class="font-medium text-right"></div>
<div class="text-gray-600">Текущая цена:</div>
<div id="modal-current-price" class="font-medium text-right"></div>
<div class="text-gray-600 font-medium text-blue-600">Новая цена:</div>
<div id="modal-new-price" class="font-bold text-right text-blue-600"></div>
</div>
<div class="text-xs text-gray-500 pt-2 border-t">
Кол-во котировок: <span id="modal-quote-count"></span>
</div>
</div>
</div>
<div class="flex justify-end gap-2 p-4 border-t">
<button onclick="closeModal()" class="px-4 py-2 border rounded hover:bg-gray-50">Отмена</button>
<button onclick="savePrice()" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">Сохранить</button>
</div>
</div>
</div>
<script>
let currentTab = 'alerts';
let currentPage = 1;
let totalPages = 1;
let perPage = 50;
let searchTimeout = null;
let currentSearch = '';
let componentsCache = [];
async function loadTab(tab) {
currentTab = tab;
currentPage = 1;
currentSearch = '';
document.getElementById('search-input').value = '';
document.getElementById('btn-alerts').className = tab === 'alerts' ? 'text-blue-600 font-medium' : 'text-gray-600';
document.getElementById('btn-components').className = tab === 'components' ? 'text-blue-600 font-medium' : 'text-gray-600';
document.getElementById('search-bar').className = tab === 'components' ? 'mb-4' : 'mb-4 hidden';
document.getElementById('pagination').className = tab === 'components' ? 'flex justify-between items-center mt-4 pt-4 border-t' : 'hidden';
await loadData();
}
async function loadData() {
const token = localStorage.getItem('token');
if (!token) {
window.location.href = '/login';
return;
}
document.getElementById('tab-content').innerHTML = '<div class="text-center py-8 text-gray-500">Загрузка...</div>';
try {
if (currentTab === 'alerts') {
const resp = await fetch('/admin/pricing/alerts?per_page=100', {
headers: {'Authorization': 'Bearer ' + token}
});
if (resp.status === 401) { logout(); return; }
if (resp.status === 403) { window.location.href = '/'; return; }
const data = await resp.json();
renderAlerts(data.alerts || []);
} else {
let url = '/admin/pricing/components?page=' + currentPage + '&per_page=' + perPage;
if (currentSearch) {
url += '&search=' + encodeURIComponent(currentSearch);
}
const resp = await fetch(url, {
headers: {'Authorization': 'Bearer ' + token}
});
if (resp.status === 401) { logout(); return; }
const data = await resp.json();
totalPages = Math.ceil(data.total / perPage);
componentsCache = data.components || [];
renderComponents(componentsCache, data.total);
updatePagination(data.total);
}
} catch(e) {
document.getElementById('tab-content').innerHTML = '<div class="text-center py-8 text-red-600">Ошибка загрузки</div>';
}
}
function debounceSearch() {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
currentSearch = document.getElementById('search-input').value;
currentPage = 1;
loadData();
}, 300);
}
function prevPage() {
if (currentPage > 1) {
currentPage--;
loadData();
}
}
function nextPage() {
if (currentPage < totalPages) {
currentPage++;
loadData();
}
}
function updatePagination(total) {
document.getElementById('page-info').textContent =
'Страница ' + currentPage + ' из ' + totalPages + ' (всего: ' + total + ')';
document.getElementById('btn-prev').disabled = currentPage <= 1;
document.getElementById('btn-next').disabled = currentPage >= totalPages;
}
function renderAlerts(alerts) {
if (alerts.length === 0) {
document.getElementById('tab-content').innerHTML = '<div class="text-center py-8 text-green-600">Нет активных алертов</div>';
return;
}
let html = '<div class="space-y-2">';
alerts.forEach(a => {
const colors = {critical: 'bg-red-100', high: 'bg-orange-100', medium: 'bg-yellow-100', low: 'bg-blue-100'};
html += '<div class="' + (colors[a.severity] || 'bg-gray-100') + ' p-3 rounded">';
html += '<div class="flex justify-between"><span class="font-medium">' + escapeHtml(a.lot_name) + '</span>';
html += '<span class="text-xs uppercase">' + a.severity + '</span></div>';
html += '<p class="text-sm text-gray-600">' + escapeHtml(a.message) + '</p></div>';
});
html += '</div>';
document.getElementById('tab-content').innerHTML = html;
}
function renderComponents(components, total) {
if (components.length === 0) {
document.getElementById('tab-content').innerHTML = '<div class="text-center py-8 text-gray-500">Нет данных</div>';
return;
}
let html = '<div class="overflow-x-auto"><table class="w-full"><thead class="bg-gray-50"><tr>';
html += '<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Артикул</th>';
html += '<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Категория</th>';
html += '<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Описание</th>';
html += '<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase">Кол-во цен</th>';
html += '<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase">Цена</th>';
html += '<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Настройки</th>';
html += '</tr></thead><tbody class="divide-y">';
components.forEach((c, idx) => {
const price = c.current_price ? '$' + parseFloat(c.current_price).toLocaleString('en-US', {minimumFractionDigits: 2}) : '—';
const category = c.category ? c.category.code : '—';
const desc = c.lot && c.lot.lot_description ? c.lot.lot_description : '—';
const quoteCount = c.quote_count || 0;
// Build settings summary
let settings = [];
const method = c.price_method || 'median';
settings.push(method === 'median' ? 'М' : 'С');
const period = c.price_period_days !== undefined && c.price_period_days !== null ? c.price_period_days : 90;
if (period === 7) settings.push('1н');
else if (period === 30) settings.push('1м');
else if (period === 90) settings.push('3м');
else if (period === 365) settings.push('1г');
else if (period === 0) settings.push('все');
else settings.push(period + 'д');
if (c.price_coefficient && c.price_coefficient !== 0) {
settings.push((c.price_coefficient > 0 ? '+' : '') + c.price_coefficient + '%');
}
if (c.manual_price && c.manual_price > 0) {
settings.push('РУЧН');
}
html += '<tr class="hover:bg-gray-50 cursor-pointer" onclick="openModal(' + idx + ')">';
html += '<td class="px-3 py-2 text-sm font-mono">' + escapeHtml(c.lot_name) + '</td>';
html += '<td class="px-3 py-2 text-sm">' + escapeHtml(category) + '</td>';
html += '<td class="px-3 py-2 text-sm text-gray-500 max-w-xs truncate">' + escapeHtml(desc) + '</td>';
html += '<td class="px-3 py-2 text-sm text-right">' + quoteCount + '</td>';
html += '<td class="px-3 py-2 text-sm text-right font-medium">' + price + '</td>';
html += '<td class="px-3 py-2 text-sm"><span class="text-xs bg-gray-100 px-2 py-1 rounded">' + settings.join(' | ') + '</span></td>';
html += '</tr>';
});
html += '</tbody></table></div>';
document.getElementById('tab-content').innerHTML = html;
}
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Modal functions
function openModal(idx) {
const c = componentsCache[idx];
if (!c) return;
document.getElementById('modal-lot-name').value = c.lot_name;
document.getElementById('modal-method').value = c.price_method || 'median';
document.getElementById('modal-period').value = String(c.price_period_days !== undefined && c.price_period_days !== null ? c.price_period_days : 90);
document.getElementById('modal-coefficient').value = c.price_coefficient || 0;
const hasManual = c.manual_price && c.manual_price > 0;
document.getElementById('modal-manual-enabled').checked = hasManual;
document.getElementById('modal-manual-price').value = hasManual ? c.manual_price : '';
document.getElementById('modal-manual-price').disabled = !hasManual;
// Reset price displays while loading
document.getElementById('modal-median-all').textContent = '...';
document.getElementById('modal-current-price').textContent = '...';
document.getElementById('modal-new-price').textContent = '...';
document.getElementById('modal-quote-count').textContent = '...';
document.getElementById('price-modal').classList.remove('hidden');
document.getElementById('price-modal').classList.add('flex');
// Fetch price preview
fetchPreview();
}
async function fetchPreview() {
const token = localStorage.getItem('token');
if (!token) return;
const lotName = document.getElementById('modal-lot-name').value;
const method = document.getElementById('modal-method').value;
const periodDays = parseInt(document.getElementById('modal-period').value) || 0;
const coefficient = parseFloat(document.getElementById('modal-coefficient').value) || 0;
try {
const resp = await fetch('/admin/pricing/preview', {
method: 'POST',
headers: {
'Authorization': 'Bearer ' + token,
'Content-Type': 'application/json'
},
body: JSON.stringify({
lot_name: lotName,
method: method,
period_days: periodDays,
coefficient: coefficient
})
});
if (resp.status === 401) { logout(); return; }
if (resp.ok) {
const data = await resp.json();
// Update median all time
document.getElementById('modal-median-all').textContent =
data.median_all_time ? '$' + parseFloat(data.median_all_time).toFixed(2) : '—';
// Update current price
document.getElementById('modal-current-price').textContent =
data.current_price ? '$' + parseFloat(data.current_price).toFixed(2) : '—';
// Update new calculated price
document.getElementById('modal-new-price').textContent =
data.new_price ? '$' + parseFloat(data.new_price).toFixed(2) : '—';
// Update quote count
document.getElementById('modal-quote-count').textContent = data.quote_count || 0;
}
} catch(e) {
console.error('Preview fetch error:', e);
document.getElementById('modal-median-all').textContent = '—';
document.getElementById('modal-new-price').textContent = '—';
}
}
function closeModal() {
document.getElementById('price-modal').classList.add('hidden');
document.getElementById('price-modal').classList.remove('flex');
}
function toggleManualPrice() {
const enabled = document.getElementById('modal-manual-enabled').checked;
document.getElementById('modal-manual-price').disabled = !enabled;
if (!enabled) {
document.getElementById('modal-manual-price').value = '';
}
fetchPreview();
}
// Debounce helper for preview updates
let previewTimeout = null;
function debounceFetchPreview() {
clearTimeout(previewTimeout);
previewTimeout = setTimeout(fetchPreview, 300);
}
async function savePrice() {
const token = localStorage.getItem('token');
if (!token) {
window.location.href = '/login';
return;
}
const lotName = document.getElementById('modal-lot-name').value;
const method = document.getElementById('modal-method').value;
const periodDaysStr = document.getElementById('modal-period').value;
const periodDays = periodDaysStr !== '' ? parseInt(periodDaysStr) : 90;
const coefficient = parseFloat(document.getElementById('modal-coefficient').value) || 0;
const manualEnabled = document.getElementById('modal-manual-enabled').checked;
const manualPrice = manualEnabled ? parseFloat(document.getElementById('modal-manual-price').value) : null;
const body = {
lot_name: lotName,
method: method,
period_days: periodDays,
coefficient: coefficient,
clear_manual: !manualEnabled
};
if (manualEnabled && manualPrice > 0) {
body.manual_price = manualPrice;
}
try {
const resp = await fetch('/admin/pricing/update', {
method: 'POST',
headers: {
'Authorization': 'Bearer ' + token,
'Content-Type': 'application/json'
},
body: JSON.stringify(body)
});
if (resp.status === 401) { logout(); return; }
if (resp.ok) {
closeModal();
loadData();
} else {
const data = await resp.json();
alert('Ошибка: ' + (data.error || 'Неизвестная ошибка'));
}
} catch(e) {
alert('Ошибка соединения');
}
}
function recalculateAll() {
const token = localStorage.getItem('token');
if (!token) {
window.location.href = '/login';
return;
}
const btn = document.getElementById('btn-recalc');
const progressContainer = document.getElementById('progress-container');
const progressBar = document.getElementById('progress-bar');
const progressText = document.getElementById('progress-text');
const progressPercent = document.getElementById('progress-percent');
const progressStats = document.getElementById('progress-stats');
// Show progress bar IMMEDIATELY
btn.disabled = true;
btn.textContent = 'Пересчёт...';
progressContainer.style.display = 'block';
progressBar.style.width = '0%';
progressBar.className = 'bg-blue-600 h-4 rounded-full transition-all duration-300';
progressText.textContent = 'Запуск пересчёта...';
progressPercent.textContent = '0%';
progressStats.textContent = 'Подготовка...';
// Use fetch with streaming for SSE
fetch('/admin/pricing/recalculate-all', {
method: 'POST',
headers: {'Authorization': 'Bearer ' + token}
}).then(response => {
const reader = response.body.getReader();
const decoder = new TextDecoder();
function read() {
reader.read().then(({done, value}) => {
if (done) {
btn.disabled = false;
btn.textContent = 'Пересчитать цены';
progressText.textContent = 'Готово!';
progressBar.className = 'bg-green-600 h-4 rounded-full';
setTimeout(() => {
progressContainer.style.display = 'none';
if (currentTab === 'components') {
loadData();
}
}, 2000);
return;
}
const text = decoder.decode(value);
const lines = text.split('\n');
lines.forEach(line => {
if (line.startsWith('data:')) {
try {
const data = JSON.parse(line.substring(5).trim());
const percent = data.total > 0 ? Math.round((data.current / data.total) * 100) : 0;
progressBar.style.width = percent + '%';
progressPercent.textContent = percent + '%';
if (data.status === 'completed') {
progressText.textContent = 'Пересчёт завершён!';
progressBar.className = 'bg-green-600 h-4 rounded-full';
} else {
progressText.textContent = 'Обработка компонентов...';
}
progressStats.textContent = 'Обновлено: ' + (data.updated || 0) + ' | Ручные: ' + (data.manual || 0) + ' | Нет данных: ' + (data.skipped || 0) + ' | Ошибок: ' + (data.errors || 0);
} catch(e) {
console.log('Parse error:', e, line);
}
}
});
read();
});
}
read();
}).catch(e => {
console.error('Fetch error:', e);
alert('Ошибка соединения');
btn.disabled = false;
btn.textContent = 'Пересчитать цены';
progressContainer.style.display = 'none';
});
}
// Close modal on click outside
document.getElementById('price-modal').addEventListener('click', function(e) {
if (e.target === this) {
closeModal();
}
});
document.addEventListener('DOMContentLoaded', () => {
loadTab('alerts');
// Add event listeners for preview updates
document.getElementById('modal-method').addEventListener('change', fetchPreview);
document.getElementById('modal-period').addEventListener('change', fetchPreview);
document.getElementById('modal-coefficient').addEventListener('input', debounceFetchPreview);
});
</script>
{{end}}
{{template "base" .}}

113
web/templates/base.html Normal file
View File

@@ -0,0 +1,113 @@
{{define "base"}}
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{template "title" .}}</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<style>
.htmx-request { opacity: 0.5; }
.line-clamp-2 { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
</style>
</head>
<body class="bg-gray-100 min-h-screen">
<nav class="bg-white shadow-sm sticky top-0 z-40">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-14">
<div class="flex items-center space-x-8">
<a href="/" class="text-xl font-bold text-blue-600">QuoteForge</a>
<div class="hidden md:flex space-x-4">
<a href="/configs" class="text-gray-600 hover:text-gray-900 px-3 py-2 text-sm" id="nav-configs" style="display:none;">Мои конфигурации</a>
<a href="/admin/pricing" class="text-gray-600 hover:text-gray-900 px-3 py-2 text-sm" id="nav-admin" style="display:none;">Цены</a>
</div>
</div>
<div class="flex items-center">
<div id="user-logged-out">
<a href="/login" class="text-blue-600 hover:text-blue-800 text-sm">Войти</a>
</div>
<div id="user-logged-in" class="hidden">
<span id="user-name" class="text-sm text-gray-700 mr-3"></span>
<button onclick="logout()" class="text-red-600 hover:text-red-800 text-sm">Выйти</button>
</div>
</div>
</div>
</div>
</nav>
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 pb-12">
{{template "content" .}}
</main>
<div id="toast" class="fixed bottom-4 right-4 z-50"></div>
<footer class="fixed bottom-0 left-0 right-0 bg-gray-800 text-gray-300 text-xs py-1 px-4">
<div class="max-w-7xl mx-auto flex justify-between">
<span id="db-status">БД: проверка...</span>
<span id="db-counts"></span>
</div>
</footer>
<script>
function initAuth() {
const token = localStorage.getItem('token');
const user = localStorage.getItem('user');
if (token && user) {
try {
const userData = JSON.parse(user);
document.getElementById('user-logged-out').classList.add('hidden');
document.getElementById('user-logged-in').classList.remove('hidden');
document.getElementById('user-name').textContent = userData.username;
document.getElementById('nav-configs').style.display = 'block';
if (userData.role === 'admin' || userData.role === 'pricing_admin') {
document.getElementById('nav-admin').style.display = 'block';
}
} catch(e) {
logout();
}
}
}
function logout() {
localStorage.removeItem('token');
localStorage.removeItem('refresh_token');
localStorage.removeItem('user');
window.location.href = '/';
}
function showToast(msg, type) {
const colors = { success: 'bg-green-500', error: 'bg-red-500', info: 'bg-blue-500' };
const el = document.getElementById('toast');
el.innerHTML = '<div class="' + (colors[type] || colors.info) + ' text-white px-4 py-2 rounded shadow">' + msg + '</div>';
setTimeout(() => el.innerHTML = '', 3000);
}
async function checkDbStatus() {
try {
const resp = await fetch('/api/db-status');
const data = await resp.json();
const statusEl = document.getElementById('db-status');
const countsEl = document.getElementById('db-counts');
if (data.connected) {
statusEl.innerHTML = '<span class="text-green-400">БД: подключено</span>';
} else {
statusEl.innerHTML = '<span class="text-red-400">БД: ошибка - ' + data.error + '</span>';
}
countsEl.textContent = 'lot: ' + data.lot_count + ' | lot_log: ' + data.lot_log_count + ' | metadata: ' + data.metadata_count;
} catch(e) {
document.getElementById('db-status').innerHTML = '<span class="text-red-400">БД: нет связи</span>';
}
}
document.addEventListener('DOMContentLoaded', function() {
initAuth();
checkDbStatus();
});
</script>
</body>
</html>
{{end}}

View File

@@ -0,0 +1,52 @@
{{define "components_list.html"}}
{{if .Components}}
<div class="bg-white rounded-lg shadow overflow-hidden">
<table class="w-full">
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Артикул</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Категория</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Описание</th>
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Цена</th>
<th class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase w-24"></th>
</tr>
</thead>
<tbody class="divide-y">
{{range .Components}}
<tr class="hover:bg-gray-50">
<td class="px-4 py-3 text-sm font-medium font-mono">{{.LotName}}</td>
<td class="px-4 py-3 text-sm">
<span class="px-2 py-1 text-xs rounded bg-gray-100">{{.Category}}</span>
</td>
<td class="px-4 py-3 text-sm text-gray-500 max-w-md truncate">{{.Description}}</td>
<td class="px-4 py-3 text-sm text-right font-medium">
{{if .CurrentPrice}}
${{printf "%.2f" (deref .CurrentPrice)}}
{{else}}
<span class="text-gray-400"></span>
{{end}}
</td>
<td class="px-4 py-3 text-center">
{{if .CurrentPrice}}
<button onclick="addToCart('{{jsesc .LotName}}', {{deref .CurrentPrice}}, '{{jsesc .Description}}')"
class="px-3 py-1 bg-blue-600 text-white text-xs rounded hover:bg-blue-700">
+ Добавить
</button>
{{else}}
<span class="text-gray-400 text-xs">Нет цены</span>
{{end}}
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
<p class="text-center text-sm text-gray-500 mt-4">Найдено: {{.Total}}</p>
{{else}}
<div class="bg-white rounded-lg shadow p-8 text-center text-gray-500">
Компоненты не найдены
</div>
{{end}}
{{end}}

199
web/templates/configs.html Normal file
View File

@@ -0,0 +1,199 @@
{{define "title"}}Мои конфигурации - QuoteForge{{end}}
{{define "content"}}
<div class="space-y-4">
<h1 class="text-2xl font-bold">Мои конфигурации</h1>
<div id="configs-list">
<div class="text-center py-8 text-gray-500">Загрузка...</div>
</div>
<div class="mt-4">
<button onclick="openCreateModal()" class="w-full py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium">
+ Создать новую конфигурацию
</button>
</div>
</div>
<!-- Modal for creating new configuration -->
<div id="create-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">Номер Opportunity</label>
<input type="text" id="opportunity-number" placeholder="Например: OPP-2024-001"
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
</div>
</div>
<div class="flex justify-end space-x-3 mt-6">
<button onclick="closeCreateModal()" class="px-4 py-2 text-gray-600 hover:text-gray-800">
Отмена
</button>
<button onclick="createConfig()" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">
Создать
</button>
</div>
</div>
</div>
<script>
async function loadConfigs() {
const token = localStorage.getItem('token');
if (!token) {
document.getElementById('configs-list').innerHTML =
'<div class="bg-white rounded-lg shadow p-8 text-center"><a href="/login" class="text-blue-600">Войдите для просмотра</a></div>';
return;
}
try {
const resp = await fetch('/api/configs', {
headers: {'Authorization': 'Bearer ' + token}
});
if (resp.status === 401) {
logout();
return;
}
if (resp.status === 403) {
document.getElementById('configs-list').innerHTML =
'<div class="bg-white rounded-lg shadow p-8 text-center text-red-600">Нет доступа</div>';
return;
}
const data = await resp.json();
renderConfigs(data.configurations || []);
} catch(e) {
document.getElementById('configs-list').innerHTML =
'<div class="bg-white rounded-lg shadow p-8 text-center text-red-600">Ошибка загрузки</div>';
}
}
function renderConfigs(configs) {
if (configs.length === 0) {
document.getElementById('configs-list').innerHTML =
'<div class="bg-white rounded-lg shadow p-8 text-center text-gray-500">Нет сохраненных конфигураций</div>';
return;
}
let html = '<div class="bg-white rounded-lg shadow overflow-hidden"><table class="w-full">';
html += '<thead class="bg-gray-50"><tr>';
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Название</th>';
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Дата</th>';
html += '<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Сумма</th>';
html += '<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Действия</th>';
html += '</tr></thead><tbody class="divide-y">';
configs.forEach(c => {
const date = new Date(c.created_at).toLocaleDateString('ru-RU');
const total = c.total_price ? '$' + c.total_price.toLocaleString('en-US', {minimumFractionDigits: 2}) : '—';
html += '<tr class="hover:bg-gray-50">';
html += '<td class="px-4 py-3 text-sm font-medium"><a href="/configurator?uuid=' + c.uuid + '" class="text-blue-600 hover:text-blue-800 hover:underline">' + escapeHtml(c.name) + '</a></td>';
html += '<td class="px-4 py-3 text-sm text-gray-500">' + date + '</td>';
html += '<td class="px-4 py-3 text-sm text-right">' + total + '</td>';
html += '<td class="px-4 py-3 text-sm text-right">';
html += '<button onclick="deleteConfig(\'' + c.uuid + '\')" class="text-red-600 hover:text-red-800">Удалить</button>';
html += '</td></tr>';
});
html += '</tbody></table></div>';
document.getElementById('configs-list').innerHTML = html;
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
async function deleteConfig(uuid) {
if (!confirm('Удалить?')) return;
const token = localStorage.getItem('token');
await fetch('/api/configs/' + uuid, {
method: 'DELETE',
headers: {'Authorization': 'Bearer ' + token}
});
loadConfigs();
}
function openCreateModal() {
const token = localStorage.getItem('token');
if (!token) {
window.location.href = '/login';
return;
}
document.getElementById('opportunity-number').value = '';
document.getElementById('create-modal').classList.remove('hidden');
document.getElementById('create-modal').classList.add('flex');
document.getElementById('opportunity-number').focus();
}
function closeCreateModal() {
document.getElementById('create-modal').classList.add('hidden');
document.getElementById('create-modal').classList.remove('flex');
}
async function createConfig() {
const token = localStorage.getItem('token');
const name = document.getElementById('opportunity-number').value.trim();
if (!name) {
alert('Введите номер Opportunity');
return;
}
try {
const resp = await fetch('/api/configs', {
method: 'POST',
headers: {
'Authorization': 'Bearer ' + token,
'Content-Type': 'application/json'
},
body: JSON.stringify({
name: name,
items: [],
notes: ''
})
});
if (resp.status === 401) {
logout();
return;
}
if (!resp.ok) {
const err = await resp.json();
alert('Ошибка: ' + (err.error || 'Не удалось создать'));
return;
}
const config = await resp.json();
window.location.href = '/configurator?uuid=' + config.uuid;
} catch(e) {
alert('Ошибка создания конфигурации');
}
}
// Close modal on outside click
document.getElementById('create-modal').addEventListener('click', function(e) {
if (e.target === this) {
closeCreateModal();
}
});
// Close modal on Escape key
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
closeCreateModal();
}
});
document.addEventListener('DOMContentLoaded', loadConfigs);
</script>
{{end}}
{{template "base" .}}

743
web/templates/index.html Normal file
View File

@@ -0,0 +1,743 @@
{{define "title"}}QuoteForge - Конфигуратор{{end}}
{{define "content"}}
<div class="space-y-4">
<!-- Header with config name and back button -->
<div class="flex items-center justify-between">
<div class="flex items-center space-x-4">
<a href="/configs" class="text-gray-500 hover:text-gray-700">
<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="M15 19l-7-7 7-7"></path>
</svg>
</a>
<h1 class="text-2xl font-bold">
<span id="config-name">Конфигуратор</span>
</h1>
</div>
<div id="save-buttons" class="hidden space-x-2">
<button onclick="saveConfig()" class="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700">
Сохранить
</button>
</div>
</div>
<!-- Category Tabs -->
<div class="bg-white rounded-lg shadow">
<div class="border-b">
<nav class="flex overflow-x-auto" id="category-tabs">
<button onclick="switchTab('base')" data-tab="base"
class="tab-btn px-4 py-3 text-sm font-medium border-b-2 border-blue-600 text-blue-600 whitespace-nowrap">
Base
</button>
<button onclick="switchTab('storage')" data-tab="storage"
class="tab-btn px-4 py-3 text-sm font-medium border-b-2 border-transparent text-gray-500 hover:text-gray-700 whitespace-nowrap">
Storage
</button>
<button onclick="switchTab('pci')" data-tab="pci"
class="tab-btn px-4 py-3 text-sm font-medium border-b-2 border-transparent text-gray-500 hover:text-gray-700 whitespace-nowrap">
PCI
</button>
<button onclick="switchTab('power')" data-tab="power"
class="tab-btn px-4 py-3 text-sm font-medium border-b-2 border-transparent text-gray-500 hover:text-gray-700 whitespace-nowrap">
Power
</button>
<button onclick="switchTab('accessories')" data-tab="accessories"
class="tab-btn px-4 py-3 text-sm font-medium border-b-2 border-transparent text-gray-500 hover:text-gray-700 whitespace-nowrap">
Accessories
</button>
<button onclick="switchTab('other')" data-tab="other"
class="tab-btn px-4 py-3 text-sm font-medium border-b-2 border-transparent text-gray-500 hover:text-gray-700 whitespace-nowrap">
Other
</button>
</nav>
</div>
<!-- Tab content -->
<div id="tab-content" class="p-4">
<div class="text-center py-8 text-gray-500">Загрузка...</div>
</div>
</div>
<!-- Cart summary -->
<div id="cart-summary" class="bg-white rounded-lg shadow p-4">
<h3 class="font-semibold mb-3">Итого конфигурация</h3>
<div id="cart-items" class="space-y-2 mb-4"></div>
<div class="border-t pt-3 flex justify-between items-center">
<div class="text-lg font-bold">
Итого: <span id="cart-total">$0.00</span>
</div>
<button onclick="exportCSV()" class="px-3 py-1 bg-gray-200 text-gray-700 rounded text-sm hover:bg-gray-300">Экспорт CSV</button>
</div>
</div>
</div>
<!-- Autocomplete dropdown (shared) -->
<div id="autocomplete-dropdown" class="hidden absolute z-50 bg-white border rounded-lg shadow-lg max-h-60 overflow-y-auto w-96"></div>
<style>
.autocomplete-item {
padding: 8px 12px;
cursor: pointer;
border-bottom: 1px solid #f0f0f0;
}
.autocomplete-item:hover, .autocomplete-item.selected {
background-color: #f3f4f6;
}
.autocomplete-item:last-child {
border-bottom: none;
}
</style>
<script>
// Tab configuration
const TAB_CONFIG = {
base: {
categories: ['MB', 'CPU', 'MEM'],
singleSelect: true,
label: 'Base'
},
storage: {
categories: ['M2', 'SSD', 'HDD', 'EDSFF', 'HHHL'],
singleSelect: false,
label: 'Storage'
},
pci: {
categories: ['HBA', 'HCA', 'NIC', 'GPU', 'RAID', 'DPU'],
singleSelect: false,
label: 'PCI'
},
power: {
categories: ['PS', 'PSU'],
singleSelect: false,
label: 'Power'
},
accessories: {
categories: ['ACC', 'CARD'],
singleSelect: false,
label: 'Accessories'
},
other: {
categories: [],
singleSelect: false,
label: 'Other'
}
};
const ASSIGNED_CATEGORIES = Object.values(TAB_CONFIG)
.flatMap(t => t.categories)
.map(c => c.toUpperCase());
// State
let configUUID = '{{.ConfigUUID}}';
let configName = '';
let currentTab = 'base';
let allComponents = [];
let cart = [];
// Autocomplete state
let autocompleteInput = null;
let autocompleteCategory = null;
let autocompleteIndex = -1;
let autocompleteFiltered = [];
// Initialize
document.addEventListener('DOMContentLoaded', async function() {
const token = localStorage.getItem('token');
if (!token || !configUUID) {
window.location.href = '/configs';
return;
}
try {
const resp = await fetch('/api/configs/' + configUUID, {
headers: {'Authorization': 'Bearer ' + token}
});
if (resp.status === 401) {
window.location.href = '/login';
return;
}
if (resp.status === 403 || resp.status === 404) {
showToast('Конфигурация не найдена', 'error');
window.location.href = '/configs';
return;
}
const config = await resp.json();
configName = config.name;
document.getElementById('config-name').textContent = config.name;
document.getElementById('save-buttons').classList.remove('hidden');
if (config.items && config.items.length > 0) {
cart = config.items.map(item => ({
lot_name: item.lot_name,
quantity: item.quantity,
unit_price: item.unit_price,
description: item.description || '',
category: item.category || getCategoryFromLotName(item.lot_name)
}));
}
} catch(e) {
showToast('Ошибка загрузки конфигурации', 'error');
window.location.href = '/configs';
return;
}
await loadAllComponents();
renderTab();
updateCartUI();
// Close autocomplete on outside click
document.addEventListener('click', function(e) {
if (!e.target.closest('.autocomplete-wrapper')) {
hideAutocomplete();
}
});
});
async function loadAllComponents() {
try {
const resp = await fetch('/api/components?per_page=5000');
const data = await resp.json();
allComponents = data.components || [];
} catch(e) {
console.error('Failed to load components', e);
allComponents = [];
}
}
function getCategoryFromLotName(lotName) {
const parts = lotName.split('_');
return parts[0] || '';
}
function getComponentCategory(comp) {
return (comp.category || getCategoryFromLotName(comp.lot_name)).toUpperCase();
}
function getTabForCategory(category) {
const cat = category.toUpperCase();
for (const [tabKey, tabConfig] of Object.entries(TAB_CONFIG)) {
if (tabConfig.categories.map(c => c.toUpperCase()).includes(cat)) {
return tabKey;
}
}
return 'other';
}
function switchTab(tab) {
currentTab = tab;
document.querySelectorAll('.tab-btn').forEach(btn => {
if (btn.dataset.tab === tab) {
btn.classList.remove('border-transparent', 'text-gray-500');
btn.classList.add('border-blue-600', 'text-blue-600');
} else {
btn.classList.add('border-transparent', 'text-gray-500');
btn.classList.remove('border-blue-600', 'text-blue-600');
}
});
hideAutocomplete();
renderTab();
}
function getComponentsForTab(tab) {
const config = TAB_CONFIG[tab];
return allComponents.filter(comp => {
const category = getComponentCategory(comp);
if (tab === 'other') {
return !ASSIGNED_CATEGORIES.includes(category);
}
return config.categories.map(c => c.toUpperCase()).includes(category);
});
}
function getComponentsForCategory(category) {
return allComponents.filter(comp => {
return getComponentCategory(comp) === category.toUpperCase();
});
}
function renderTab() {
const config = TAB_CONFIG[currentTab];
const components = getComponentsForTab(currentTab);
if (config.singleSelect) {
renderSingleSelectTab(config.categories);
} else {
renderMultiSelectTab(components);
}
}
function renderSingleSelectTab(categories) {
let html = `
<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 w-24">Тип</th>
<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">
`;
categories.forEach(cat => {
const catLabel = cat === 'MB' ? 'MB' : cat === 'CPU' ? 'CPU' : cat === 'MEM' ? 'MEM' : cat;
const selectedItem = cart.find(item =>
(item.category || getCategoryFromLotName(item.lot_name)).toUpperCase() === cat.toUpperCase()
);
const comp = selectedItem ? allComponents.find(c => c.lot_name === selectedItem.lot_name) : null;
const price = comp?.current_price || 0;
const qty = selectedItem?.quantity || 1;
const total = price * qty;
html += `
<tr class="hover:bg-gray-50">
<td class="px-3 py-2 text-sm font-medium text-gray-700">${catLabel}</td>
<td class="px-3 py-2">
<div class="autocomplete-wrapper relative">
<input type="text"
id="input-${cat}"
value="${selectedItem?.lot_name || ''}"
placeholder="Введите артикул..."
class="w-full px-2 py-1 border rounded text-sm font-mono"
onfocus="showAutocomplete('${cat}', this)"
oninput="filterAutocomplete('${cat}', this.value)"
onkeydown="handleAutocompleteKey(event, '${cat}')">
</div>
</td>
<td class="px-3 py-2 text-sm text-gray-500 truncate max-w-xs" id="desc-${cat}">${escapeHtml(comp?.description || '')}</td>
<td class="px-3 py-2 text-sm text-right" id="price-${cat}">${price ? '$' + price.toFixed(2) : '—'}</td>
<td class="px-3 py-2 text-center">
<input type="number" min="1" value="${qty}"
id="qty-${cat}"
onchange="updateSingleQuantity('${cat}', 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" id="total-${cat}">${total ? '$' + total.toFixed(2) : '—'}</td>
<td class="px-3 py-2 text-center">
${selectedItem ? `
<button onclick="clearSingleSelect('${cat}')" 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>
`;
});
html += '</tbody></table>';
document.getElementById('tab-content').innerHTML = html;
}
function renderMultiSelectTab(components) {
// 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 = `
<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 tab
tabItems.forEach((item, idx) => {
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
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-new-item"
placeholder="Добавить компонент..."
class="w-full px-2 py-1 border rounded text-sm"
onfocus="showAutocompleteMulti(this)"
oninput="filterAutocompleteMulti(this.value)"
onkeydown="handleAutocompleteKeyMulti(event)">
</div>
</td>
<td class="px-3 py-2 text-sm text-right text-gray-400" id="new-price">—</td>
<td class="px-3 py-2 text-center">
<input type="number" min="1" value="1" id="new-qty"
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">—</td>
<td class="px-3 py-2"></td>
</tr>
`;
html += '</tbody></table>';
html += `<p class="text-center text-sm text-gray-500 mt-4">Доступно компонентов: ${components.length}</p>`;
document.getElementById('tab-content').innerHTML = html;
}
// Autocomplete for single select (Base tab)
function showAutocomplete(category, input) {
autocompleteInput = input;
autocompleteCategory = category;
autocompleteIndex = -1;
filterAutocomplete(category, input.value);
}
function filterAutocomplete(category, search) {
const components = getComponentsForCategory(category);
const searchLower = search.toLowerCase();
autocompleteFiltered = components.filter(c => {
if (!c.current_price) return false;
const text = (c.lot_name + ' ' + (c.description || '')).toLowerCase();
return text.includes(searchLower);
}).slice(0, 50);
renderAutocomplete();
}
function renderAutocomplete() {
const dropdown = document.getElementById('autocomplete-dropdown');
if (autocompleteFiltered.length === 0 || !autocompleteInput) {
dropdown.classList.add('hidden');
return;
}
const rect = autocompleteInput.getBoundingClientRect();
dropdown.style.top = (rect.bottom + window.scrollY) + 'px';
dropdown.style.left = rect.left + 'px';
dropdown.style.width = Math.max(rect.width, 400) + 'px';
dropdown.innerHTML = autocompleteFiltered.map((comp, idx) => `
<div class="autocomplete-item ${idx === autocompleteIndex ? 'selected' : ''}"
onmousedown="selectAutocompleteItem(${idx})">
<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');
}
function handleAutocompleteKey(event, category) {
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) {
selectAutocompleteItem(autocompleteIndex);
}
} else if (event.key === 'Escape') {
hideAutocomplete();
}
}
function selectAutocompleteItem(index) {
const comp = autocompleteFiltered[index];
if (!comp || !autocompleteCategory) return;
// Remove existing item of this category
cart = cart.filter(item =>
(item.category || getCategoryFromLotName(item.lot_name)).toUpperCase() !== autocompleteCategory.toUpperCase()
);
const qtyInput = document.getElementById('qty-' + autocompleteCategory);
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();
renderTab();
updateCartUI();
}
function hideAutocomplete() {
document.getElementById('autocomplete-dropdown').classList.add('hidden');
autocompleteInput = null;
autocompleteCategory = null;
autocompleteIndex = -1;
}
// Autocomplete for multi select tabs
function showAutocompleteMulti(input) {
autocompleteInput = input;
autocompleteCategory = null;
autocompleteIndex = -1;
filterAutocompleteMulti(input.value);
}
function filterAutocompleteMulti(search) {
const components = getComponentsForTab(currentTab);
const searchLower = search.toLowerCase();
// Filter out already added items
const addedLots = new Set(cart.map(i => i.lot_name));
autocompleteFiltered = components.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);
}).slice(0, 50);
renderAutocomplete();
}
function handleAutocompleteKeyMulti(event) {
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) {
selectAutocompleteItemMulti(autocompleteIndex);
}
} else if (event.key === 'Escape') {
hideAutocomplete();
}
}
function selectAutocompleteItemMulti(index) {
const comp = autocompleteFiltered[index];
if (!comp) return;
const qtyInput = document.getElementById('new-qty');
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();
renderTab();
updateCartUI();
}
function clearSingleSelect(category) {
cart = cart.filter(item =>
(item.category || getCategoryFromLotName(item.lot_name)).toUpperCase() !== category.toUpperCase()
);
renderTab();
updateCartUI();
}
function updateSingleQuantity(category, value) {
const qty = parseInt(value) || 1;
const item = cart.find(i =>
(i.category || getCategoryFromLotName(i.lot_name)).toUpperCase() === category.toUpperCase()
);
if (item) {
item.quantity = Math.max(1, qty);
renderTab();
updateCartUI();
}
}
function updateMultiQuantity(lotName, value) {
const qty = parseInt(value) || 1;
const item = cart.find(i => i.lot_name === lotName);
if (item) {
item.quantity = Math.max(1, qty);
updateCartUI();
// Update total in the row
const row = document.querySelector(`input[onchange*="${lotName}"]`)?.closest('tr');
if (row) {
const totalCell = row.querySelector('td:nth-child(5)');
if (totalCell) {
totalCell.textContent = '$' + (item.unit_price * item.quantity).toFixed(2);
}
}
}
}
function removeFromCart(lotName) {
cart = cart.filter(i => i.lot_name !== lotName);
renderTab();
updateCartUI();
}
function updateCartUI() {
const total = cart.reduce((sum, item) => sum + (item.unit_price * item.quantity), 0);
document.getElementById('cart-total').textContent = '$' + total.toLocaleString('en-US', {minimumFractionDigits: 2});
if (cart.length === 0) {
document.getElementById('cart-items').innerHTML =
'<div class="text-gray-500 text-center py-2">Конфигурация пуста</div>';
return;
}
const grouped = {};
cart.forEach(item => {
const cat = item.category || getCategoryFromLotName(item.lot_name);
const tab = getTabForCategory(cat);
if (!grouped[tab]) grouped[tab] = [];
grouped[tab].push(item);
});
let html = '';
for (const [tab, items] of Object.entries(grouped)) {
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>`;
items.forEach(item => {
const itemTotal = item.unit_price * item.quantity;
html += `
<div class="flex justify-between items-center py-1 text-sm">
<div class="flex-1">
<span class="font-mono">${escapeHtml(item.lot_name)}</span>
<span class="text-gray-400 mx-1">×</span>
<span>${item.quantity}</span>
</div>
<div class="flex items-center space-x-2">
<span>$${itemTotal.toLocaleString('en-US', {minimumFractionDigits: 2})}</span>
<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>
</div>
</div>
`;
});
html += '</div>';
}
document.getElementById('cart-items').innerHTML = html;
}
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
async function saveConfig() {
const token = localStorage.getItem('token');
if (!token || !configUUID) return;
try {
const resp = await fetch('/api/configs/' + configUUID, {
method: 'PUT',
headers: {
'Authorization': 'Bearer ' + token,
'Content-Type': 'application/json'
},
body: JSON.stringify({
name: configName,
items: cart,
notes: ''
})
});
if (resp.status === 401) {
window.location.href = '/login';
return;
}
if (!resp.ok) {
showToast('Ошибка сохранения', 'error');
return;
}
showToast('Сохранено', 'success');
} catch(e) {
showToast('Ошибка сохранения', 'error');
}
}
async function exportCSV() {
if (cart.length === 0) return;
try {
const resp = await fetch('/api/export/csv', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({items: cart, name: configName})
});
const blob = await resp.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = (configName || 'config') + '.csv';
a.click();
window.URL.revokeObjectURL(url);
} catch(e) {
showToast('Ошибка экспорта', 'error');
}
}
</script>
{{end}}
{{template "base" .}}

82
web/templates/login.html Normal file
View File

@@ -0,0 +1,82 @@
{{define "title"}}Вход - QuoteForge{{end}}
{{define "content"}}
<div class="max-w-sm mx-auto mt-16">
<div class="bg-white rounded-lg shadow p-6">
<h1 class="text-xl font-bold text-center mb-6">Вход в систему</h1>
<form id="login-form">
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-1">Логин</label>
<input type="text" name="username" id="username" required
class="w-full px-3 py-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
value="admin">
</div>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-1">Пароль</label>
<input type="password" name="password" id="password" required
class="w-full px-3 py-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
value="admin123">
</div>
<div id="error" class="text-red-600 text-sm mb-4 hidden"></div>
<button type="submit" id="submit-btn"
class="w-full bg-blue-600 text-white py-2 rounded hover:bg-blue-700">
Войти
</button>
</form>
<p class="text-center text-sm text-gray-500 mt-4">
<a href="/" class="text-blue-600">На главную</a>
</p>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
const form = document.getElementById('login-form');
if (!form) return;
form.addEventListener('submit', async function(e) {
e.preventDefault();
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
const errorEl = document.getElementById('error');
const btn = document.getElementById('submit-btn');
errorEl.classList.add('hidden');
btn.disabled = true;
btn.textContent = 'Вход...';
try {
const resp = await fetch('/api/auth/login', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({username, password})
});
const data = await resp.json();
if (resp.ok && data.access_token) {
localStorage.setItem('token', data.access_token);
localStorage.setItem('refresh_token', data.refresh_token);
localStorage.setItem('user', JSON.stringify(data.user));
window.location.href = '/configs';
} else {
errorEl.textContent = data.error || 'Неверный логин или пароль';
errorEl.classList.remove('hidden');
btn.disabled = false;
btn.textContent = 'Войти';
}
} catch(err) {
errorEl.textContent = 'Ошибка соединения с сервером';
errorEl.classList.remove('hidden');
btn.disabled = false;
btn.textContent = 'Войти';
}
});
});
</script>
{{end}}
{{template "base" .}}