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:
@@ -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
9
go.mod
@@ -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
15
go.sum
@@ -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=
|
||||||
|
|||||||
@@ -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
186
internal/handlers/web.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
9
web/static/app.css
Normal 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;
|
||||||
|
}
|
||||||
549
web/templates/admin_pricing.html
Normal file
549
web/templates/admin_pricing.html
Normal 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">×</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
113
web/templates/base.html
Normal 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}}
|
||||||
52
web/templates/components_list.html
Normal file
52
web/templates/components_list.html
Normal 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
199
web/templates/configs.html
Normal 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
743
web/templates/index.html
Normal 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
82
web/templates/login.html
Normal 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" .}}
|
||||||
Reference in New Issue
Block a user