Compare commits
2 Commits
2510d9e36e
...
feature/ph
| Author | SHA1 | Date | |
|---|---|---|---|
| 20056f3593 | |||
|
|
8d84484412 |
21
assets_embed.go
Normal file
21
assets_embed.go
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
package quoteforge
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"io/fs"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TemplatesFS contains HTML templates embedded into the binary.
|
||||||
|
//
|
||||||
|
//go:embed web/templates/*.html web/templates/partials/*.html
|
||||||
|
var TemplatesFS embed.FS
|
||||||
|
|
||||||
|
// StaticFiles contains static assets (CSS, JS, etc.) embedded into the binary.
|
||||||
|
//
|
||||||
|
//go:embed web/static/*
|
||||||
|
var StaticFiles embed.FS
|
||||||
|
|
||||||
|
// StaticFS returns a filesystem rooted at web/static for serving static assets.
|
||||||
|
func StaticFS() (fs.FS, error) {
|
||||||
|
return fs.Sub(StaticFiles, "web/static")
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@ import (
|
|||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
qfassets "git.mchus.pro/mchus/quoteforge"
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/config"
|
"git.mchus.pro/mchus/quoteforge/internal/config"
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/db"
|
"git.mchus.pro/mchus/quoteforge/internal/db"
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/handlers"
|
"git.mchus.pro/mchus/quoteforge/internal/handlers"
|
||||||
@@ -25,6 +25,7 @@ import (
|
|||||||
"git.mchus.pro/mchus/quoteforge/internal/services/pricelist"
|
"git.mchus.pro/mchus/quoteforge/internal/services/pricelist"
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/services/pricing"
|
"git.mchus.pro/mchus/quoteforge/internal/services/pricing"
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/services/sync"
|
"git.mchus.pro/mchus/quoteforge/internal/services/sync"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
"gorm.io/driver/mysql"
|
"gorm.io/driver/mysql"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"gorm.io/gorm/logger"
|
"gorm.io/gorm/logger"
|
||||||
@@ -56,38 +57,54 @@ func main() {
|
|||||||
// Load config for server settings (optional)
|
// Load config for server settings (optional)
|
||||||
cfg, err := config.Load(*configPath)
|
cfg, err := config.Load(*configPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
// Use defaults if config file doesn't exist
|
// Use defaults if config file doesn't exist
|
||||||
slog.Info("config file not found, using defaults", "path", *configPath)
|
slog.Info("config file not found, using defaults", "path", *configPath)
|
||||||
cfg = &config.Config{}
|
cfg = &config.Config{}
|
||||||
|
} else {
|
||||||
|
slog.Error("failed to load config", "path", *configPath, "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
setConfigDefaults(cfg)
|
setConfigDefaults(cfg)
|
||||||
|
|
||||||
setupLogger(cfg.Logging)
|
setupLogger(cfg.Logging)
|
||||||
|
|
||||||
// Create connection manager (lazy connection, no connect on startup)
|
// Create connection manager and try to connect immediately if settings exist
|
||||||
connMgr := db.NewConnectionManager(local)
|
connMgr := db.NewConnectionManager(local)
|
||||||
slog.Info("starting in offline-first mode")
|
|
||||||
|
|
||||||
dbUser := local.GetDBUser()
|
dbUser := local.GetDBUser()
|
||||||
|
|
||||||
// In offline-first mode, use default user ID
|
|
||||||
// EnsureDBUser will be called lazily when sync happens
|
|
||||||
dbUserID := uint(1)
|
dbUserID := uint(1)
|
||||||
|
|
||||||
|
// Try to connect to MariaDB on startup
|
||||||
|
mariaDB, err := connMgr.GetDB()
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("failed to connect to MariaDB on startup, starting in offline mode", "error", err)
|
||||||
|
mariaDB = nil
|
||||||
|
} else {
|
||||||
|
slog.Info("successfully connected to MariaDB on startup")
|
||||||
|
// Ensure DB user exists and get their ID
|
||||||
|
if dbUserID, err = models.EnsureDBUser(mariaDB, dbUser); err != nil {
|
||||||
|
slog.Error("failed to ensure DB user", "error", err)
|
||||||
|
// Continue with default ID
|
||||||
|
dbUserID = uint(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
slog.Info("starting QuoteForge server",
|
slog.Info("starting QuoteForge server",
|
||||||
"host", cfg.Server.Host,
|
"host", cfg.Server.Host,
|
||||||
"port", cfg.Server.Port,
|
"port", cfg.Server.Port,
|
||||||
"db_user", dbUser,
|
"db_user", dbUser,
|
||||||
"db_user_id", dbUserID,
|
"db_user_id", dbUserID,
|
||||||
|
"online", mariaDB != nil,
|
||||||
)
|
)
|
||||||
|
|
||||||
if *migrate {
|
if *migrate {
|
||||||
slog.Info("running database migrations...")
|
if mariaDB == nil {
|
||||||
mariaDB, err := connMgr.GetDB()
|
slog.Error("cannot run migrations: database not available")
|
||||||
if err != nil {
|
|
||||||
slog.Error("cannot run migrations: database not available", "error", err)
|
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
slog.Info("running database migrations...")
|
||||||
if err := models.Migrate(mariaDB); err != nil {
|
if err := models.Migrate(mariaDB); err != nil {
|
||||||
slog.Error("migration failed", "error", err)
|
slog.Error("migration failed", "error", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
@@ -100,7 +117,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
gin.SetMode(cfg.Server.Mode)
|
gin.SetMode(cfg.Server.Mode)
|
||||||
router, syncService, err := setupRouter(cfg, local, connMgr, dbUserID)
|
router, syncService, err := setupRouter(cfg, local, connMgr, mariaDB, dbUserID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("failed to setup router", "error", err)
|
slog.Error("failed to setup router", "error", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
@@ -189,7 +206,8 @@ func setConfigDefaults(cfg *config.Config) {
|
|||||||
func runSetupMode(local *localdb.LocalDB) {
|
func runSetupMode(local *localdb.LocalDB) {
|
||||||
restartSig := make(chan struct{}, 1)
|
restartSig := make(chan struct{}, 1)
|
||||||
|
|
||||||
setupHandler, err := handlers.NewSetupHandler(local, "web/templates", restartSig)
|
// In setup mode, we don't have a connection manager yet (will restart after setup)
|
||||||
|
setupHandler, err := handlers.NewSetupHandler(local, nil, "web/templates", restartSig)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("failed to create setup handler", "error", err)
|
slog.Error("failed to create setup handler", "error", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
@@ -199,7 +217,11 @@ func runSetupMode(local *localdb.LocalDB) {
|
|||||||
router := gin.New()
|
router := gin.New()
|
||||||
router.Use(gin.Recovery())
|
router.Use(gin.Recovery())
|
||||||
|
|
||||||
|
if stat, err := os.Stat("web/static"); err == nil && stat.IsDir() {
|
||||||
router.Static("/static", "web/static")
|
router.Static("/static", "web/static")
|
||||||
|
} else if staticFS, err := qfassets.StaticFS(); err == nil {
|
||||||
|
router.StaticFS("/static", http.FS(staticFS))
|
||||||
|
}
|
||||||
|
|
||||||
// Setup routes only
|
// Setup routes only
|
||||||
router.GET("/", func(c *gin.Context) {
|
router.GET("/", func(c *gin.Context) {
|
||||||
@@ -300,10 +322,8 @@ func setupDatabaseFromDSN(dsn string) (*gorm.DB, error) {
|
|||||||
return db, nil
|
return db, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.ConnectionManager, dbUserID uint) (*gin.Engine, *sync.Service, error) {
|
func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.ConnectionManager, mariaDB *gorm.DB, dbUserID uint) (*gin.Engine, *sync.Service, error) {
|
||||||
// Don't connect to MariaDB on startup (offline-first architecture)
|
// mariaDB may be nil if we're in offline mode
|
||||||
// Connection will be established lazily when needed
|
|
||||||
var mariaDB *gorm.DB
|
|
||||||
|
|
||||||
// Repositories
|
// Repositories
|
||||||
var componentRepo *repository.ComponentRepository
|
var componentRepo *repository.ComponentRepository
|
||||||
@@ -375,7 +395,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Setup handler (for reconfiguration) - no restart signal in normal mode
|
// Setup handler (for reconfiguration) - no restart signal in normal mode
|
||||||
setupHandler, err := handlers.NewSetupHandler(local, "web/templates", nil)
|
setupHandler, err := handlers.NewSetupHandler(local, connMgr, "web/templates", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, fmt.Errorf("creating setup handler: %w", err)
|
return nil, nil, fmt.Errorf("creating setup handler: %w", err)
|
||||||
}
|
}
|
||||||
@@ -394,7 +414,11 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
router.Use(middleware.OfflineDetector(connMgr, local))
|
router.Use(middleware.OfflineDetector(connMgr, local))
|
||||||
|
|
||||||
// Static files
|
// Static files
|
||||||
|
if stat, err := os.Stat("web/static"); err == nil && stat.IsDir() {
|
||||||
router.Static("/static", "web/static")
|
router.Static("/static", "web/static")
|
||||||
|
} else if staticFS, err := qfassets.StaticFS(); err == nil {
|
||||||
|
router.StaticFS("/static", http.FS(staticFS))
|
||||||
|
}
|
||||||
|
|
||||||
// Health check
|
// Health check
|
||||||
router.GET("/health", func(c *gin.Context) {
|
router.GET("/health", func(c *gin.Context) {
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/services"
|
"git.mchus.pro/mchus/quoteforge/internal/services"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ComponentHandler struct {
|
type ComponentHandler struct {
|
||||||
@@ -40,7 +40,13 @@ func (h *ComponentHandler) List(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// If offline mode (empty result), fallback to local components
|
// If offline mode (empty result), fallback to local components
|
||||||
if result.Total == 0 && h.localDB != nil {
|
isOffline := false
|
||||||
|
if v, ok := c.Get("is_offline"); ok {
|
||||||
|
if b, ok := v.(bool); ok {
|
||||||
|
isOffline = b
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if isOffline && result.Total == 0 && h.localDB != nil {
|
||||||
localFilter := localdb.ComponentFilter{
|
localFilter := localdb.ComponentFilter{
|
||||||
Category: filter.Category,
|
Category: filter.Category,
|
||||||
Search: filter.Search,
|
Search: filter.Search,
|
||||||
|
|||||||
@@ -639,6 +639,18 @@ func (h *PricingHandler) RecalculateAll(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *PricingHandler) ListAlerts(c *gin.Context) {
|
func (h *PricingHandler) ListAlerts(c *gin.Context) {
|
||||||
|
// Check if we're in offline mode
|
||||||
|
if h.db == nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"alerts": []interface{}{},
|
||||||
|
"total": 0,
|
||||||
|
"page": 1,
|
||||||
|
"per_page": 20,
|
||||||
|
"offline": true,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "20"))
|
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "20"))
|
||||||
|
|
||||||
@@ -664,6 +676,15 @@ func (h *PricingHandler) ListAlerts(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *PricingHandler) AcknowledgeAlert(c *gin.Context) {
|
func (h *PricingHandler) AcknowledgeAlert(c *gin.Context) {
|
||||||
|
// Check if we're in offline mode
|
||||||
|
if h.db == nil {
|
||||||
|
c.JSON(http.StatusServiceUnavailable, gin.H{
|
||||||
|
"error": "Управление алертами доступно только в онлайн режиме",
|
||||||
|
"offline": true,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid alert id"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid alert id"})
|
||||||
@@ -679,6 +700,15 @@ func (h *PricingHandler) AcknowledgeAlert(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *PricingHandler) ResolveAlert(c *gin.Context) {
|
func (h *PricingHandler) ResolveAlert(c *gin.Context) {
|
||||||
|
// Check if we're in offline mode
|
||||||
|
if h.db == nil {
|
||||||
|
c.JSON(http.StatusServiceUnavailable, gin.H{
|
||||||
|
"error": "Управление алертами доступно только в онлайн режиме",
|
||||||
|
"offline": true,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid alert id"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid alert id"})
|
||||||
@@ -694,6 +724,15 @@ func (h *PricingHandler) ResolveAlert(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *PricingHandler) IgnoreAlert(c *gin.Context) {
|
func (h *PricingHandler) IgnoreAlert(c *gin.Context) {
|
||||||
|
// Check if we're in offline mode
|
||||||
|
if h.db == nil {
|
||||||
|
c.JSON(http.StatusServiceUnavailable, gin.H{
|
||||||
|
"error": "Управление алертами доступно только в онлайн режиме",
|
||||||
|
"offline": true,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid alert id"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid alert id"})
|
||||||
|
|||||||
@@ -3,13 +3,17 @@ package handlers
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
qfassets "git.mchus.pro/mchus/quoteforge"
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/db"
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
"gorm.io/driver/mysql"
|
"gorm.io/driver/mysql"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"gorm.io/gorm/logger"
|
"gorm.io/gorm/logger"
|
||||||
@@ -17,11 +21,12 @@ import (
|
|||||||
|
|
||||||
type SetupHandler struct {
|
type SetupHandler struct {
|
||||||
localDB *localdb.LocalDB
|
localDB *localdb.LocalDB
|
||||||
|
connMgr *db.ConnectionManager
|
||||||
templates map[string]*template.Template
|
templates map[string]*template.Template
|
||||||
restartSig chan struct{}
|
restartSig chan struct{}
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewSetupHandler(localDB *localdb.LocalDB, templatesPath string, restartSig chan struct{}) (*SetupHandler, error) {
|
func NewSetupHandler(localDB *localdb.LocalDB, connMgr *db.ConnectionManager, templatesPath string, restartSig chan struct{}) (*SetupHandler, error) {
|
||||||
funcMap := template.FuncMap{
|
funcMap := template.FuncMap{
|
||||||
"sub": func(a, b int) int { return a - b },
|
"sub": func(a, b int) int { return a - b },
|
||||||
"add": func(a, b int) int { return a + b },
|
"add": func(a, b int) int { return a + b },
|
||||||
@@ -31,7 +36,13 @@ func NewSetupHandler(localDB *localdb.LocalDB, templatesPath string, restartSig
|
|||||||
|
|
||||||
// Load setup template (standalone, no base needed)
|
// Load setup template (standalone, no base needed)
|
||||||
setupPath := filepath.Join(templatesPath, "setup.html")
|
setupPath := filepath.Join(templatesPath, "setup.html")
|
||||||
tmpl, err := template.New("").Funcs(funcMap).ParseFiles(setupPath)
|
var tmpl *template.Template
|
||||||
|
var err error
|
||||||
|
if stat, statErr := os.Stat(templatesPath); statErr == nil && stat.IsDir() {
|
||||||
|
tmpl, err = template.New("").Funcs(funcMap).ParseFiles(setupPath)
|
||||||
|
} else {
|
||||||
|
tmpl, err = template.New("").Funcs(funcMap).ParseFS(qfassets.TemplatesFS, "web/templates/setup.html")
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("parsing setup template: %w", err)
|
return nil, fmt.Errorf("parsing setup template: %w", err)
|
||||||
}
|
}
|
||||||
@@ -39,6 +50,7 @@ func NewSetupHandler(localDB *localdb.LocalDB, templatesPath string, restartSig
|
|||||||
|
|
||||||
return &SetupHandler{
|
return &SetupHandler{
|
||||||
localDB: localDB,
|
localDB: localDB,
|
||||||
|
connMgr: connMgr,
|
||||||
templates: templates,
|
templates: templates,
|
||||||
restartSig: restartSig,
|
restartSig: restartSig,
|
||||||
}, nil
|
}, nil
|
||||||
@@ -181,12 +193,23 @@ func (h *SetupHandler) SaveConnection(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Try to connect immediately to verify settings
|
||||||
|
if h.connMgr != nil {
|
||||||
|
if err := h.connMgr.TryConnect(); err != nil {
|
||||||
|
slog.Warn("failed to connect after saving settings", "error", err)
|
||||||
|
} else {
|
||||||
|
slog.Info("successfully connected to database after saving settings")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always restart to properly initialize all services with the new connection
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"success": true,
|
"success": true,
|
||||||
"message": "Settings saved. Restarting application...",
|
"message": "Settings saved. Please restart the application to apply changes.",
|
||||||
|
"restart_required": true,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Signal restart after response is sent
|
// Signal restart after response is sent (if restart signal is configured)
|
||||||
if h.restartSig != nil {
|
if h.restartSig != nil {
|
||||||
go func() {
|
go func() {
|
||||||
time.Sleep(500 * time.Millisecond) // Give time for response to be sent
|
time.Sleep(500 * time.Millisecond) // Give time for response to be sent
|
||||||
|
|||||||
@@ -4,13 +4,15 @@ import (
|
|||||||
"html/template"
|
"html/template"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
qfassets "git.mchus.pro/mchus/quoteforge"
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/db"
|
"git.mchus.pro/mchus/quoteforge/internal/db"
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/services/sync"
|
"git.mchus.pro/mchus/quoteforge/internal/services/sync"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
// SyncHandler handles sync API endpoints
|
// SyncHandler handles sync API endpoints
|
||||||
@@ -25,7 +27,13 @@ type SyncHandler struct {
|
|||||||
func NewSyncHandler(localDB *localdb.LocalDB, syncService *sync.Service, connMgr *db.ConnectionManager, templatesPath string) (*SyncHandler, error) {
|
func NewSyncHandler(localDB *localdb.LocalDB, syncService *sync.Service, connMgr *db.ConnectionManager, templatesPath string) (*SyncHandler, error) {
|
||||||
// Load sync_status partial template
|
// Load sync_status partial template
|
||||||
partialPath := filepath.Join(templatesPath, "partials", "sync_status.html")
|
partialPath := filepath.Join(templatesPath, "partials", "sync_status.html")
|
||||||
tmpl, err := template.ParseFiles(partialPath)
|
var tmpl *template.Template
|
||||||
|
var err error
|
||||||
|
if stat, statErr := os.Stat(templatesPath); statErr == nil && stat.IsDir() {
|
||||||
|
tmpl, err = template.ParseFiles(partialPath)
|
||||||
|
} else {
|
||||||
|
tmpl, err = template.ParseFS(qfassets.TemplatesFS, "web/templates/partials/sync_status.html")
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,14 @@ package handlers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"html/template"
|
"html/template"
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
qfassets "git.mchus.pro/mchus/quoteforge"
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/services"
|
"git.mchus.pro/mchus/quoteforge/internal/services"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
type WebHandler struct {
|
type WebHandler struct {
|
||||||
@@ -59,12 +61,26 @@ func NewWebHandler(templatesPath string, componentService *services.ComponentSer
|
|||||||
|
|
||||||
templates := make(map[string]*template.Template)
|
templates := make(map[string]*template.Template)
|
||||||
basePath := filepath.Join(templatesPath, "base.html")
|
basePath := filepath.Join(templatesPath, "base.html")
|
||||||
|
useDisk := false
|
||||||
|
if stat, statErr := os.Stat(templatesPath); statErr == nil && stat.IsDir() {
|
||||||
|
useDisk = true
|
||||||
|
}
|
||||||
|
|
||||||
// Load each page template with base
|
// Load each page template with base
|
||||||
simplePages := []string{"login.html", "configs.html", "admin_pricing.html", "pricelists.html", "pricelist_detail.html"}
|
simplePages := []string{"login.html", "configs.html", "admin_pricing.html", "pricelists.html", "pricelist_detail.html"}
|
||||||
for _, page := range simplePages {
|
for _, page := range simplePages {
|
||||||
pagePath := filepath.Join(templatesPath, page)
|
pagePath := filepath.Join(templatesPath, page)
|
||||||
tmpl, err := template.New("").Funcs(funcMap).ParseFiles(basePath, pagePath)
|
var tmpl *template.Template
|
||||||
|
var err error
|
||||||
|
if useDisk {
|
||||||
|
tmpl, err = template.New("").Funcs(funcMap).ParseFiles(basePath, pagePath)
|
||||||
|
} else {
|
||||||
|
tmpl, err = template.New("").Funcs(funcMap).ParseFS(
|
||||||
|
qfassets.TemplatesFS,
|
||||||
|
"web/templates/base.html",
|
||||||
|
"web/templates/"+page,
|
||||||
|
)
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -74,7 +90,18 @@ func NewWebHandler(templatesPath string, componentService *services.ComponentSer
|
|||||||
// Index page needs components_list.html as well
|
// Index page needs components_list.html as well
|
||||||
indexPath := filepath.Join(templatesPath, "index.html")
|
indexPath := filepath.Join(templatesPath, "index.html")
|
||||||
componentsListPath := filepath.Join(templatesPath, "components_list.html")
|
componentsListPath := filepath.Join(templatesPath, "components_list.html")
|
||||||
indexTmpl, err := template.New("").Funcs(funcMap).ParseFiles(basePath, indexPath, componentsListPath)
|
var indexTmpl *template.Template
|
||||||
|
var err error
|
||||||
|
if useDisk {
|
||||||
|
indexTmpl, err = template.New("").Funcs(funcMap).ParseFiles(basePath, indexPath, componentsListPath)
|
||||||
|
} else {
|
||||||
|
indexTmpl, err = template.New("").Funcs(funcMap).ParseFS(
|
||||||
|
qfassets.TemplatesFS,
|
||||||
|
"web/templates/base.html",
|
||||||
|
"web/templates/index.html",
|
||||||
|
"web/templates/components_list.html",
|
||||||
|
)
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -84,7 +111,16 @@ func NewWebHandler(templatesPath string, componentService *services.ComponentSer
|
|||||||
partials := []string{"components_list.html"}
|
partials := []string{"components_list.html"}
|
||||||
for _, partial := range partials {
|
for _, partial := range partials {
|
||||||
partialPath := filepath.Join(templatesPath, partial)
|
partialPath := filepath.Join(templatesPath, partial)
|
||||||
tmpl, err := template.New("").Funcs(funcMap).ParseFiles(partialPath)
|
var tmpl *template.Template
|
||||||
|
var err error
|
||||||
|
if useDisk {
|
||||||
|
tmpl, err = template.New("").Funcs(funcMap).ParseFiles(partialPath)
|
||||||
|
} else {
|
||||||
|
tmpl, err = template.New("").Funcs(funcMap).ParseFS(
|
||||||
|
qfassets.TemplatesFS,
|
||||||
|
"web/templates/"+partial,
|
||||||
|
)
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ func (s *Service) CalculatePrice(lotName string, method models.PriceMethod, peri
|
|||||||
case models.PriceMethodAverage:
|
case models.PriceMethodAverage:
|
||||||
return CalculateAverage(prices), nil
|
return CalculateAverage(prices), nil
|
||||||
case models.PriceMethodWeightedMedian:
|
case models.PriceMethodWeightedMedian:
|
||||||
return CalculateWeightedMedian(points, s.config.DefaultPeriodDays), nil
|
return CalculateWeightedMedian(points, periodDays), nil
|
||||||
case models.PriceMethodMedian:
|
case models.PriceMethodMedian:
|
||||||
fallthrough
|
fallthrough
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -61,6 +61,9 @@ func (s *QuoteService) ValidateAndCalculate(req *QuoteRequest) (*QuoteValidation
|
|||||||
if len(req.Items) == 0 {
|
if len(req.Items) == 0 {
|
||||||
return nil, ErrEmptyQuote
|
return nil, ErrEmptyQuote
|
||||||
}
|
}
|
||||||
|
if s.componentRepo == nil || s.pricingService == nil {
|
||||||
|
return nil, errors.New("offline mode: quote calculation not available")
|
||||||
|
}
|
||||||
|
|
||||||
result := &QuoteValidationResult{
|
result := &QuoteValidationResult{
|
||||||
Valid: true,
|
Valid: true,
|
||||||
|
|||||||
@@ -134,12 +134,16 @@ func (s *Service) SyncPricelists() (int, error) {
|
|||||||
|
|
||||||
synced := 0
|
synced := 0
|
||||||
var latestLocalID uint
|
var latestLocalID uint
|
||||||
|
var latestServerID uint
|
||||||
for _, pl := range serverPricelists {
|
for _, pl := range serverPricelists {
|
||||||
// Check if pricelist already exists locally
|
// Check if pricelist already exists locally
|
||||||
existing, _ := s.localDB.GetLocalPricelistByServerID(pl.ID)
|
existing, _ := s.localDB.GetLocalPricelistByServerID(pl.ID)
|
||||||
if existing != nil {
|
if existing != nil {
|
||||||
// Already synced, track latest
|
// Already synced, track latest by server ID
|
||||||
|
if pl.ID > latestServerID {
|
||||||
|
latestServerID = pl.ID
|
||||||
latestLocalID = existing.ID
|
latestLocalID = existing.ID
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,7 +171,10 @@ func (s *Service) SyncPricelists() (int, error) {
|
|||||||
slog.Debug("synced pricelist with items", "version", pl.Version, "items", itemCount)
|
slog.Debug("synced pricelist with items", "version", pl.Version, "items", itemCount)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if pl.ID > latestServerID {
|
||||||
|
latestServerID = pl.ID
|
||||||
latestLocalID = localPL.ID
|
latestLocalID = localPL.ID
|
||||||
|
}
|
||||||
synced++
|
synced++
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -87,12 +87,14 @@
|
|||||||
<script>
|
<script>
|
||||||
function showStatus(message, type) {
|
function showStatus(message, type) {
|
||||||
const status = document.getElementById('status');
|
const status = document.getElementById('status');
|
||||||
status.classList.remove('hidden', 'bg-green-100', 'text-green-800', 'bg-red-100', 'text-red-800', 'bg-blue-100', 'text-blue-800');
|
status.classList.remove('hidden', 'bg-green-100', 'text-green-800', 'bg-red-100', 'text-red-800', 'bg-blue-100', 'text-blue-800', 'bg-yellow-100', 'text-yellow-800');
|
||||||
|
|
||||||
if (type === 'success') {
|
if (type === 'success') {
|
||||||
status.classList.add('bg-green-100', 'text-green-800');
|
status.classList.add('bg-green-100', 'text-green-800');
|
||||||
} else if (type === 'error') {
|
} else if (type === 'error') {
|
||||||
status.classList.add('bg-red-100', 'text-red-800');
|
status.classList.add('bg-red-100', 'text-red-800');
|
||||||
|
} else if (type === 'warning') {
|
||||||
|
status.classList.add('bg-yellow-100', 'text-yellow-800');
|
||||||
} else {
|
} else {
|
||||||
status.classList.add('bg-blue-100', 'text-blue-800');
|
status.classList.add('bg-blue-100', 'text-blue-800');
|
||||||
}
|
}
|
||||||
@@ -171,12 +173,21 @@
|
|||||||
|
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
showStatus('✓ ' + data.message, 'success');
|
showStatus('✓ ' + data.message, 'success');
|
||||||
// Wait for restart and redirect to home
|
|
||||||
|
// Check if restart is required
|
||||||
|
if (data.restart_required) {
|
||||||
|
// In normal mode, restart must be done manually
|
||||||
|
setTimeout(() => {
|
||||||
|
showStatus('⚠️ Пожалуйста, перезапустите приложение вручную для применения изменений', 'warning');
|
||||||
|
}, 2000);
|
||||||
|
} else {
|
||||||
|
// In setup mode, auto-restart is happening
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
showStatus('✓ Настройки сохранены. Проверка подключения...', 'success');
|
showStatus('✓ Настройки сохранены. Проверка подключения...', 'success');
|
||||||
// Poll until server is back
|
// Poll until server is back
|
||||||
checkServerReady();
|
checkServerReady();
|
||||||
}, 2000);
|
}, 2000);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
showStatus(data.error, 'error');
|
showStatus(data.error, 'error');
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user