2 Commits

Author SHA1 Message Date
20056f3593 Embed assets and fix offline/sync/pricing issues 2026-02-03 21:58:02 +03:00
Mikhail Chusavitin
8d84484412 fix: fix online mode after offline-first architecture changes
- Fix nil pointer dereference in PricingHandler alert methods
- Add automatic MariaDB connection on startup if settings exist
- Update setupRouter to accept mariaDB as parameter
- Fix offline mode checks: use h.db instead of h.alertService
- Update setup handler to show restart required message
- Add warning status support in setup.html UI

This ensures that after saving connection settings, the application
works correctly in online mode after restart. All repositories are
properly initialized with MariaDB connection on startup.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-03 10:50:07 +03:00
11 changed files with 270 additions and 92 deletions

21
assets_embed.go Normal file
View 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")
}

View File

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

View File

@@ -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,

View File

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

View File

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

View File

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

View File

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

View File

@@ -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:

View File

@@ -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,

View File

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

View File

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