Compare commits
15 Commits
693c1d05d7
...
v0.2.8
| Author | SHA1 | Date | |
|---|---|---|---|
| 20056f3593 | |||
|
|
8d84484412 | ||
| 2510d9e36e | |||
| d7285fc730 | |||
| e33a3f2c88 | |||
| 4735e2b9bb | |||
| cdf5cef2cf | |||
| 7f030e7db7 | |||
| 3d222b7f14 | |||
| c024b96de7 | |||
| 2c75a7ccb8 | |||
|
|
f25477a25e | ||
|
|
0bde12a39d | ||
|
|
e0404186ad | ||
|
|
eda0e7cb47 |
43
CLAUDE.md
43
CLAUDE.md
@@ -46,30 +46,31 @@
|
|||||||
**TODO:**
|
**TODO:**
|
||||||
- ❌ Conflict resolution (Phase 4, last-write-wins default)
|
- ❌ Conflict resolution (Phase 4, last-write-wins default)
|
||||||
|
|
||||||
### UI Improvements 🔶 IN PROGRESS
|
### UI Improvements ✅ MOSTLY DONE
|
||||||
|
|
||||||
**1. Sync icon + pricelist badge в header (tasks 4+2):**
|
**1. Sync UI + pricelist badge: ✅ DONE**
|
||||||
- ❌ `sync_status.html`: заменить текст Online/Offline на SVG иконку
|
- ✅ `sync_status.html`: SVG иконки Online/Offline (кликабельные → открывают модал)
|
||||||
- ❌ Кнопка sync → иконка (circular arrows) вместо текста
|
- ✅ Кнопка sync → иконка circular arrows (только full sync)
|
||||||
- ❌ Dropdown при клике: Push changes, Full sync, статус последней синхронизации
|
- ✅ Модальное окно "Статус системы" в `base.html` (info о БД, ошибки синхронизации)
|
||||||
- ❌ `configs.html`: рядом с кнопкой "Создать" показать badge с версией активного прайслиста
|
- ✅ `configs.html`: badge с версией активного прайслиста
|
||||||
- ❌ Загружать через `/api/pricelists/latest` при DOMContentLoaded
|
- ✅ Загрузка через `/api/pricelists/latest` при DOMContentLoaded
|
||||||
|
- ✅ Удалён dropdown с Push changes (упрощение UI)
|
||||||
|
|
||||||
**2. Прайслисты → вкладка в "Администратор цен" (task 1):**
|
**2. Прайслисты → вкладка в "Администратор цен": ✅ DONE**
|
||||||
- ❌ `base.html`: убрать отдельную ссылку "Прайслисты" из навигации
|
- ✅ `base.html`: убрана ссылка "Прайслисты" из навигации
|
||||||
- ❌ `admin_pricing.html`: добавить 4-ю вкладку "Прайслисты"
|
- ✅ `admin_pricing.html`: добавлена вкладка "Прайслисты"
|
||||||
- ❌ Перенести логику из `pricelists.html` (table, create modal, CRUD) в эту вкладку
|
- ✅ Логика перенесена из `pricelists.html` (table, create modal, CRUD)
|
||||||
- ❌ Route `/pricelists` → редирект на `/admin/pricing?tab=pricelists` или удалить
|
- ✅ Route `/pricelists` → редирект на `/admin/pricing?tab=pricelists`
|
||||||
|
- ✅ Поддержка URL param `?tab=pricelists`
|
||||||
|
|
||||||
**3. Страница настроек: расширить + синхронизация (task 3):**
|
**3. Модал "Настройка цены" - кол-во котировок с учётом периода: ❌ TODO**
|
||||||
- ❌ `setup.html`: переделать на `{{template "base" .}}` структуру
|
- Текущее: показывает только общее кол-во котировок
|
||||||
- ❌ Увеличить до `max-w-4xl`, разделить на 2 секции
|
- Новое: показывать `N (всего: M)` где N - за выбранный период, M - всего
|
||||||
- ❌ Секция A: Подключение к БД (текущая форма)
|
- ❌ `admin_pricing.html`: обновить `#modal-quote-count`
|
||||||
- ❌ Секция B: Синхронизация данных:
|
- ❌ `admin_pricing_handler.go`: в `/api/admin/pricing/preview` возвращать `quote_count_period` + `quote_count_total`
|
||||||
- Статус Online/Offline
|
|
||||||
- Кнопки: "Синхронизировать всё", "Обновить компоненты", "Обновить прайслисты"
|
**4. Страница настроек: ❌ ОТЛОЖЕНО**
|
||||||
- Журнал синхронизации (последние N операций)
|
- Перенесено в Phase 3 (после основных UI улучшений)
|
||||||
- ❌ Возможно: новый API endpoint для sync log
|
|
||||||
|
|
||||||
### Phase 3: Projects and Specifications
|
### Phase 3: Projects and Specifications
|
||||||
- qt_projects, qt_specifications tables (MariaDB)
|
- qt_projects, qt_specifications tables (MariaDB)
|
||||||
|
|||||||
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,8 +12,9 @@ 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/handlers"
|
"git.mchus.pro/mchus/quoteforge/internal/handlers"
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/middleware"
|
"git.mchus.pro/mchus/quoteforge/internal/middleware"
|
||||||
@@ -24,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"
|
||||||
@@ -55,36 +57,38 @@ 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 {
|
||||||
// Use defaults if config file doesn't exist
|
if os.IsNotExist(err) {
|
||||||
slog.Info("config file not found, using defaults", "path", *configPath)
|
// Use defaults if config file doesn't exist
|
||||||
cfg = &config.Config{}
|
slog.Info("config file not found, using defaults", "path", *configPath)
|
||||||
|
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)
|
||||||
|
|
||||||
// Get DSN from local SQLite
|
// Create connection manager and try to connect immediately if settings exist
|
||||||
dsn, err := local.GetDSN()
|
connMgr := db.NewConnectionManager(local)
|
||||||
if err != nil {
|
|
||||||
slog.Error("failed to get database settings", "error", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Connect to MariaDB
|
|
||||||
db, err := setupDatabaseFromDSN(dsn)
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("failed to connect to database", "error", err)
|
|
||||||
slog.Info("you may need to reconfigure connection at /setup")
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
dbUser := local.GetDBUser()
|
dbUser := local.GetDBUser()
|
||||||
|
dbUserID := uint(1)
|
||||||
|
|
||||||
// Ensure DB user exists in qt_users table (for foreign key constraint)
|
// Try to connect to MariaDB on startup
|
||||||
dbUserID, err := models.EnsureDBUser(db, dbUser)
|
mariaDB, err := connMgr.GetDB()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("failed to ensure DB user exists", "error", err)
|
slog.Warn("failed to connect to MariaDB on startup, starting in offline mode", "error", err)
|
||||||
os.Exit(1)
|
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",
|
||||||
@@ -92,15 +96,20 @@ func main() {
|
|||||||
"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 {
|
||||||
|
if mariaDB == nil {
|
||||||
|
slog.Error("cannot run migrations: database not available")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
slog.Info("running database migrations...")
|
slog.Info("running database migrations...")
|
||||||
if err := models.Migrate(db); 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)
|
||||||
}
|
}
|
||||||
if err := models.SeedCategories(db); err != nil {
|
if err := models.SeedCategories(mariaDB); err != nil {
|
||||||
slog.Error("seeding categories failed", "error", err)
|
slog.Error("seeding categories failed", "error", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
@@ -108,17 +117,17 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
gin.SetMode(cfg.Server.Mode)
|
gin.SetMode(cfg.Server.Mode)
|
||||||
router, syncService, err := setupRouter(db, cfg, local, 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start background sync worker
|
// Start background sync worker (will auto-skip when offline)
|
||||||
workerCtx, workerCancel := context.WithCancel(context.Background())
|
workerCtx, workerCancel := context.WithCancel(context.Background())
|
||||||
defer workerCancel()
|
defer workerCancel()
|
||||||
|
|
||||||
syncWorker := sync.NewWorker(syncService, db, 5*time.Minute)
|
syncWorker := sync.NewWorker(syncService, connMgr, 5*time.Minute)
|
||||||
go syncWorker.Start(workerCtx)
|
go syncWorker.Start(workerCtx)
|
||||||
|
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
@@ -197,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)
|
||||||
@@ -207,7 +217,11 @@ func runSetupMode(local *localdb.LocalDB) {
|
|||||||
router := gin.New()
|
router := gin.New()
|
||||||
router.Use(gin.Recovery())
|
router.Use(gin.Recovery())
|
||||||
|
|
||||||
router.Static("/static", "web/static")
|
if stat, err := os.Stat("web/static"); err == nil && stat.IsDir() {
|
||||||
|
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) {
|
||||||
@@ -308,50 +322,80 @@ func setupDatabaseFromDSN(dsn string) (*gorm.DB, error) {
|
|||||||
return db, nil
|
return db, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func setupRouter(db *gorm.DB, cfg *config.Config, local *localdb.LocalDB, 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) {
|
||||||
|
// mariaDB may be nil if we're in offline mode
|
||||||
|
|
||||||
// Repositories
|
// Repositories
|
||||||
componentRepo := repository.NewComponentRepository(db)
|
var componentRepo *repository.ComponentRepository
|
||||||
categoryRepo := repository.NewCategoryRepository(db)
|
var categoryRepo *repository.CategoryRepository
|
||||||
priceRepo := repository.NewPriceRepository(db)
|
var priceRepo *repository.PriceRepository
|
||||||
alertRepo := repository.NewAlertRepository(db)
|
var alertRepo *repository.AlertRepository
|
||||||
statsRepo := repository.NewStatsRepository(db)
|
var statsRepo *repository.StatsRepository
|
||||||
pricelistRepo := repository.NewPricelistRepository(db)
|
var pricelistRepo *repository.PricelistRepository
|
||||||
configRepo := repository.NewConfigurationRepository(db)
|
|
||||||
|
// Only initialize repositories if we have a database connection
|
||||||
|
if mariaDB != nil {
|
||||||
|
componentRepo = repository.NewComponentRepository(mariaDB)
|
||||||
|
categoryRepo = repository.NewCategoryRepository(mariaDB)
|
||||||
|
priceRepo = repository.NewPriceRepository(mariaDB)
|
||||||
|
alertRepo = repository.NewAlertRepository(mariaDB)
|
||||||
|
statsRepo = repository.NewStatsRepository(mariaDB)
|
||||||
|
pricelistRepo = repository.NewPricelistRepository(mariaDB)
|
||||||
|
} else {
|
||||||
|
// In offline mode, we'll use nil repositories or handle them differently
|
||||||
|
// This is handled in the sync service and other components
|
||||||
|
}
|
||||||
|
|
||||||
// Services
|
// Services
|
||||||
pricingService := pricing.NewService(componentRepo, priceRepo, cfg.Pricing)
|
var pricingService *pricing.Service
|
||||||
componentService := services.NewComponentService(componentRepo, categoryRepo, statsRepo)
|
var componentService *services.ComponentService
|
||||||
quoteService := services.NewQuoteService(componentRepo, statsRepo, pricingService)
|
var quoteService *services.QuoteService
|
||||||
exportService := services.NewExportService(cfg.Export, categoryRepo)
|
var exportService *services.ExportService
|
||||||
alertService := alerts.NewService(alertRepo, componentRepo, priceRepo, statsRepo, cfg.Alerts, cfg.Pricing)
|
var alertService *alerts.Service
|
||||||
pricelistService := pricelist.NewService(db, pricelistRepo, componentRepo)
|
var pricelistService *pricelist.Service
|
||||||
syncService := sync.NewService(pricelistRepo, configRepo, local)
|
var syncService *sync.Service
|
||||||
|
|
||||||
|
// Sync service always uses ConnectionManager (works offline and online)
|
||||||
|
syncService = sync.NewService(connMgr, local)
|
||||||
|
|
||||||
|
if mariaDB != nil {
|
||||||
|
pricingService = pricing.NewService(componentRepo, priceRepo, cfg.Pricing)
|
||||||
|
componentService = services.NewComponentService(componentRepo, categoryRepo, statsRepo)
|
||||||
|
quoteService = services.NewQuoteService(componentRepo, statsRepo, pricingService)
|
||||||
|
exportService = services.NewExportService(cfg.Export, categoryRepo)
|
||||||
|
alertService = alerts.NewService(alertRepo, componentRepo, priceRepo, statsRepo, cfg.Alerts, cfg.Pricing)
|
||||||
|
pricelistService = pricelist.NewService(mariaDB, pricelistRepo, componentRepo)
|
||||||
|
} else {
|
||||||
|
// In offline mode, we still need to create services that don't require DB
|
||||||
|
pricingService = pricing.NewService(nil, nil, cfg.Pricing)
|
||||||
|
componentService = services.NewComponentService(nil, nil, nil)
|
||||||
|
quoteService = services.NewQuoteService(nil, nil, pricingService)
|
||||||
|
exportService = services.NewExportService(cfg.Export, nil)
|
||||||
|
alertService = alerts.NewService(nil, nil, nil, nil, cfg.Alerts, cfg.Pricing)
|
||||||
|
pricelistService = pricelist.NewService(nil, nil, nil)
|
||||||
|
}
|
||||||
|
|
||||||
// isOnline function for local-first architecture
|
// isOnline function for local-first architecture
|
||||||
isOnline := func() bool {
|
isOnline := func() bool {
|
||||||
sqlDB, err := db.DB()
|
return connMgr.IsOnline()
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return sqlDB.Ping() == nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Local-first configuration service (replaces old ConfigurationService)
|
// Local-first configuration service (replaces old ConfigurationService)
|
||||||
configService := services.NewLocalConfigurationService(local, syncService, quoteService, isOnline)
|
configService := services.NewLocalConfigurationService(local, syncService, quoteService, isOnline)
|
||||||
|
|
||||||
// Handlers
|
// Handlers
|
||||||
componentHandler := handlers.NewComponentHandler(componentService)
|
componentHandler := handlers.NewComponentHandler(componentService, local)
|
||||||
quoteHandler := handlers.NewQuoteHandler(quoteService)
|
quoteHandler := handlers.NewQuoteHandler(quoteService)
|
||||||
exportHandler := handlers.NewExportHandler(exportService, configService, componentService)
|
exportHandler := handlers.NewExportHandler(exportService, configService, componentService)
|
||||||
pricingHandler := handlers.NewPricingHandler(db, pricingService, alertService, componentRepo, priceRepo, statsRepo)
|
pricingHandler := handlers.NewPricingHandler(mariaDB, pricingService, alertService, componentRepo, priceRepo, statsRepo)
|
||||||
pricelistHandler := handlers.NewPricelistHandler(pricelistService, local)
|
pricelistHandler := handlers.NewPricelistHandler(pricelistService, local)
|
||||||
syncHandler, err := handlers.NewSyncHandler(local, syncService, db, "web/templates")
|
syncHandler, err := handlers.NewSyncHandler(local, syncService, connMgr, "web/templates")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, fmt.Errorf("creating sync handler: %w", err)
|
return nil, nil, fmt.Errorf("creating sync handler: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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)
|
||||||
}
|
}
|
||||||
@@ -367,10 +411,14 @@ func setupRouter(db *gorm.DB, cfg *config.Config, local *localdb.LocalDB, dbUser
|
|||||||
router.Use(gin.Recovery())
|
router.Use(gin.Recovery())
|
||||||
router.Use(requestLogger())
|
router.Use(requestLogger())
|
||||||
router.Use(middleware.CORS())
|
router.Use(middleware.CORS())
|
||||||
router.Use(middleware.OfflineDetector(db, local))
|
router.Use(middleware.OfflineDetector(connMgr, local))
|
||||||
|
|
||||||
// Static files
|
// Static files
|
||||||
router.Static("/static", "web/static")
|
if stat, err := os.Stat("web/static"); err == nil && stat.IsDir() {
|
||||||
|
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) {
|
||||||
@@ -383,22 +431,28 @@ func setupRouter(db *gorm.DB, cfg *config.Config, local *localdb.LocalDB, dbUser
|
|||||||
// DB status endpoint
|
// DB status endpoint
|
||||||
router.GET("/api/db-status", func(c *gin.Context) {
|
router.GET("/api/db-status", func(c *gin.Context) {
|
||||||
var lotCount, lotLogCount, metadataCount int64
|
var lotCount, lotLogCount, metadataCount int64
|
||||||
var dbOK bool = true
|
var dbOK bool = false
|
||||||
var dbError string
|
var dbError string
|
||||||
|
|
||||||
sqlDB, err := db.DB()
|
// Check if connection exists (fast check, no reconnect attempt)
|
||||||
if err != nil {
|
status := connMgr.GetStatus()
|
||||||
dbOK = false
|
if status.IsConnected {
|
||||||
dbError = err.Error()
|
// Already connected, safe to use
|
||||||
} else if err := sqlDB.Ping(); err != nil {
|
if db, err := connMgr.GetDB(); err == nil && db != nil {
|
||||||
dbOK = false
|
dbOK = true
|
||||||
dbError = err.Error()
|
db.Table("lot").Count(&lotCount)
|
||||||
|
db.Table("lot_log").Count(&lotLogCount)
|
||||||
|
db.Table("qt_lot_metadata").Count(&metadataCount)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Not connected - don't try to reconnect on status check
|
||||||
|
// This prevents 3s timeout on every request
|
||||||
|
dbError = "Database not connected (offline mode)"
|
||||||
|
if status.LastError != "" {
|
||||||
|
dbError = status.LastError
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
db.Table("lot").Count(&lotCount)
|
|
||||||
db.Table("lot_log").Count(&lotLogCount)
|
|
||||||
db.Table("qt_lot_metadata").Count(&metadataCount)
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"connected": dbOK,
|
"connected": dbOK,
|
||||||
"error": dbError,
|
"error": dbError,
|
||||||
@@ -625,6 +679,7 @@ func setupRouter(db *gorm.DB, cfg *config.Config, local *localdb.LocalDB, dbUser
|
|||||||
syncAPI := api.Group("/sync")
|
syncAPI := api.Group("/sync")
|
||||||
{
|
{
|
||||||
syncAPI.GET("/status", syncHandler.GetStatus)
|
syncAPI.GET("/status", syncHandler.GetStatus)
|
||||||
|
syncAPI.GET("/info", syncHandler.GetInfo)
|
||||||
syncAPI.POST("/components", syncHandler.SyncComponents)
|
syncAPI.POST("/components", syncHandler.SyncComponents)
|
||||||
syncAPI.POST("/pricelists", syncHandler.SyncPricelists)
|
syncAPI.POST("/pricelists", syncHandler.SyncPricelists)
|
||||||
syncAPI.POST("/all", syncHandler.SyncAll)
|
syncAPI.POST("/all", syncHandler.SyncAll)
|
||||||
|
|||||||
328
internal/db/connection.go
Normal file
328
internal/db/connection.go
Normal file
@@ -0,0 +1,328 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||||
|
"gorm.io/driver/mysql"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultConnectTimeout = 5 * time.Second
|
||||||
|
defaultPingInterval = 30 * time.Second
|
||||||
|
defaultReconnectCooldown = 10 * time.Second
|
||||||
|
|
||||||
|
maxOpenConns = 10
|
||||||
|
maxIdleConns = 2
|
||||||
|
connMaxLifetime = 5 * time.Minute
|
||||||
|
)
|
||||||
|
|
||||||
|
// ConnectionStatus represents the current status of the database connection
|
||||||
|
type ConnectionStatus struct {
|
||||||
|
IsConnected bool
|
||||||
|
LastCheck time.Time
|
||||||
|
LastError string // empty if no error
|
||||||
|
DSNHost string // host:port for display (without password!)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConnectionManager manages database connections with thread-safety and connection pooling
|
||||||
|
type ConnectionManager struct {
|
||||||
|
localDB *localdb.LocalDB // for getting DSN from settings
|
||||||
|
mu sync.RWMutex // protects db and state
|
||||||
|
db *gorm.DB // current connection (nil if not connected)
|
||||||
|
lastError error // last connection error
|
||||||
|
lastCheck time.Time // time of last check/attempt
|
||||||
|
connectTimeout time.Duration // timeout for connection (default: 5s)
|
||||||
|
pingInterval time.Duration // minimum interval between pings (default: 30s)
|
||||||
|
reconnectCooldown time.Duration // pause after failed attempt (default: 10s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewConnectionManager creates a new ConnectionManager instance
|
||||||
|
func NewConnectionManager(localDB *localdb.LocalDB) *ConnectionManager {
|
||||||
|
return &ConnectionManager{
|
||||||
|
localDB: localDB,
|
||||||
|
connectTimeout: defaultConnectTimeout,
|
||||||
|
pingInterval: defaultPingInterval,
|
||||||
|
reconnectCooldown: defaultReconnectCooldown,
|
||||||
|
db: nil,
|
||||||
|
lastError: nil,
|
||||||
|
lastCheck: time.Time{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDB returns the current database connection, establishing it if needed
|
||||||
|
// Thread-safe and respects connection cooldowns
|
||||||
|
func (cm *ConnectionManager) GetDB() (*gorm.DB, error) {
|
||||||
|
// Handle case where localDB is nil
|
||||||
|
if cm.localDB == nil {
|
||||||
|
return nil, fmt.Errorf("local database not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
// First check if we already have a valid connection
|
||||||
|
cm.mu.RLock()
|
||||||
|
if cm.db != nil {
|
||||||
|
// Check if connection is still valid and within ping interval
|
||||||
|
if time.Since(cm.lastCheck) < cm.pingInterval {
|
||||||
|
cm.mu.RUnlock()
|
||||||
|
return cm.db, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cm.mu.RUnlock()
|
||||||
|
|
||||||
|
// Upgrade to write lock
|
||||||
|
cm.mu.Lock()
|
||||||
|
defer cm.mu.Unlock()
|
||||||
|
|
||||||
|
// Double-check: someone else might have connected while we were waiting for the write lock
|
||||||
|
if cm.db != nil {
|
||||||
|
// Check if connection is still valid and within ping interval
|
||||||
|
if time.Since(cm.lastCheck) < cm.pingInterval {
|
||||||
|
return cm.db, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we're in cooldown period after a failed attempt
|
||||||
|
if cm.lastError != nil && time.Since(cm.lastCheck) < cm.reconnectCooldown {
|
||||||
|
return nil, cm.lastError
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attempt to connect
|
||||||
|
err := cm.connect()
|
||||||
|
if err != nil {
|
||||||
|
cm.lastError = err
|
||||||
|
cm.lastCheck = time.Now()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update last check time and return success
|
||||||
|
cm.lastCheck = time.Now()
|
||||||
|
cm.lastError = nil
|
||||||
|
return cm.db, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// connect establishes a new database connection
|
||||||
|
func (cm *ConnectionManager) connect() error {
|
||||||
|
// Get DSN from local settings
|
||||||
|
dsn, err := cm.localDB.GetDSN()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("getting DSN: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create context with timeout
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), cm.connectTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Open database connection
|
||||||
|
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
|
||||||
|
Logger: logger.Default.LogMode(logger.Silent),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("opening database connection: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test the connection
|
||||||
|
sqlDB, err := db.DB()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("getting sql.DB: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ping with timeout
|
||||||
|
if err = sqlDB.PingContext(ctx); err != nil {
|
||||||
|
return fmt.Errorf("pinging database: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set connection pool settings
|
||||||
|
sqlDB.SetMaxOpenConns(maxOpenConns)
|
||||||
|
sqlDB.SetMaxIdleConns(maxIdleConns)
|
||||||
|
sqlDB.SetConnMaxLifetime(connMaxLifetime)
|
||||||
|
|
||||||
|
// Store the connection
|
||||||
|
cm.db = db
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsOnline checks if the database is currently connected and responsive
|
||||||
|
// Does not attempt to reconnect, only checks current state with caching
|
||||||
|
func (cm *ConnectionManager) IsOnline() bool {
|
||||||
|
cm.mu.RLock()
|
||||||
|
if cm.db == nil {
|
||||||
|
cm.mu.RUnlock()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we've checked recently, return cached result
|
||||||
|
if time.Since(cm.lastCheck) < cm.pingInterval {
|
||||||
|
cm.mu.RUnlock()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
cm.mu.RUnlock()
|
||||||
|
|
||||||
|
// Need to perform actual ping
|
||||||
|
cm.mu.Lock()
|
||||||
|
defer cm.mu.Unlock()
|
||||||
|
|
||||||
|
// Double-check after acquiring write lock
|
||||||
|
if cm.db == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Perform ping with timeout
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), cm.connectTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
sqlDB, err := cm.db.DB()
|
||||||
|
if err != nil {
|
||||||
|
cm.lastError = err
|
||||||
|
cm.lastCheck = time.Now()
|
||||||
|
cm.db = nil
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = sqlDB.PingContext(ctx); err != nil {
|
||||||
|
cm.lastError = err
|
||||||
|
cm.lastCheck = time.Now()
|
||||||
|
cm.db = nil
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update last check time and return success
|
||||||
|
cm.lastCheck = time.Now()
|
||||||
|
cm.lastError = nil
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// TryConnect forces a new connection attempt (for UI "Reconnect" button)
|
||||||
|
// Ignores cooldown period
|
||||||
|
func (cm *ConnectionManager) TryConnect() error {
|
||||||
|
cm.mu.Lock()
|
||||||
|
defer cm.mu.Unlock()
|
||||||
|
|
||||||
|
// Attempt to connect
|
||||||
|
err := cm.connect()
|
||||||
|
if err != nil {
|
||||||
|
cm.lastError = err
|
||||||
|
cm.lastCheck = time.Now()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update last check time and clear error
|
||||||
|
cm.lastCheck = time.Now()
|
||||||
|
cm.lastError = nil
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disconnect closes the current database connection
|
||||||
|
func (cm *ConnectionManager) Disconnect() {
|
||||||
|
cm.mu.Lock()
|
||||||
|
defer cm.mu.Unlock()
|
||||||
|
|
||||||
|
if cm.db != nil {
|
||||||
|
sqlDB, err := cm.db.DB()
|
||||||
|
if err == nil {
|
||||||
|
sqlDB.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cm.db = nil
|
||||||
|
cm.lastError = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLastError returns the last connection error (thread-safe)
|
||||||
|
func (cm *ConnectionManager) GetLastError() error {
|
||||||
|
cm.mu.RLock()
|
||||||
|
defer cm.mu.RUnlock()
|
||||||
|
return cm.lastError
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetStatus returns the current connection status
|
||||||
|
func (cm *ConnectionManager) GetStatus() ConnectionStatus {
|
||||||
|
cm.mu.RLock()
|
||||||
|
defer cm.mu.RUnlock()
|
||||||
|
|
||||||
|
status := ConnectionStatus{
|
||||||
|
IsConnected: cm.db != nil,
|
||||||
|
LastCheck: cm.lastCheck,
|
||||||
|
LastError: "",
|
||||||
|
DSNHost: "",
|
||||||
|
}
|
||||||
|
|
||||||
|
if cm.lastError != nil {
|
||||||
|
status.LastError = cm.lastError.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract host from DSN for display
|
||||||
|
if cm.localDB != nil {
|
||||||
|
if dsn, err := cm.localDB.GetDSN(); err == nil {
|
||||||
|
// Parse DSN to extract host:port
|
||||||
|
// Format: user:password@tcp(host:port)/database?...
|
||||||
|
status.DSNHost = extractHostFromDSN(dsn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return status
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractHostFromDSN extracts the host:port part from a DSN string
|
||||||
|
func extractHostFromDSN(dsn string) string {
|
||||||
|
// Find the tcp( part
|
||||||
|
tcpStart := 0
|
||||||
|
if tcpStart = len("tcp("); tcpStart < len(dsn) && dsn[tcpStart] == '(' {
|
||||||
|
// Look for the closing parenthesis
|
||||||
|
parenEnd := -1
|
||||||
|
for i := tcpStart + 1; i < len(dsn); i++ {
|
||||||
|
if dsn[i] == ')' {
|
||||||
|
parenEnd = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if parenEnd != -1 {
|
||||||
|
// Extract host:port part between tcp( and )
|
||||||
|
hostPort := dsn[tcpStart+1:parenEnd]
|
||||||
|
return hostPort
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: try to find host:port by looking for @tcp( pattern
|
||||||
|
atIndex := -1
|
||||||
|
for i := 0; i < len(dsn)-4; i++ {
|
||||||
|
if dsn[i:i+4] == "@tcp" {
|
||||||
|
atIndex = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if atIndex != -1 {
|
||||||
|
// Look for the opening parenthesis after @tcp
|
||||||
|
parenStart := -1
|
||||||
|
for i := atIndex + 4; i < len(dsn); i++ {
|
||||||
|
if dsn[i] == '(' {
|
||||||
|
parenStart = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if parenStart != -1 {
|
||||||
|
// Look for the closing parenthesis
|
||||||
|
parenEnd := -1
|
||||||
|
for i := parenStart + 1; i < len(dsn); i++ {
|
||||||
|
if dsn[i] == ')' {
|
||||||
|
parenEnd = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if parenEnd != -1 {
|
||||||
|
hostPort := dsn[parenStart+1:parenEnd]
|
||||||
|
return hostPort
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we can't parse it, return empty string
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -4,17 +4,22 @@ 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/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 {
|
||||||
componentService *services.ComponentService
|
componentService *services.ComponentService
|
||||||
|
localDB *localdb.LocalDB
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewComponentHandler(componentService *services.ComponentService) *ComponentHandler {
|
func NewComponentHandler(componentService *services.ComponentService, localDB *localdb.LocalDB) *ComponentHandler {
|
||||||
return &ComponentHandler{componentService: componentService}
|
return &ComponentHandler{
|
||||||
|
componentService: componentService,
|
||||||
|
localDB: localDB,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *ComponentHandler) List(c *gin.Context) {
|
func (h *ComponentHandler) List(c *gin.Context) {
|
||||||
@@ -34,6 +39,46 @@ func (h *ComponentHandler) List(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If offline mode (empty result), fallback to local components
|
||||||
|
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{
|
||||||
|
Category: filter.Category,
|
||||||
|
Search: filter.Search,
|
||||||
|
HasPrice: filter.HasPrice,
|
||||||
|
}
|
||||||
|
|
||||||
|
offset := (page - 1) * perPage
|
||||||
|
localComps, total, err := h.localDB.ListComponents(localFilter, offset, perPage)
|
||||||
|
if err == nil && len(localComps) > 0 {
|
||||||
|
// Convert local components to ComponentView format
|
||||||
|
components := make([]services.ComponentView, len(localComps))
|
||||||
|
for i, lc := range localComps {
|
||||||
|
components[i] = services.ComponentView{
|
||||||
|
LotName: lc.LotName,
|
||||||
|
Description: lc.LotDescription,
|
||||||
|
Category: lc.Category,
|
||||||
|
CategoryName: lc.Category, // No translation in local mode
|
||||||
|
Model: lc.Model,
|
||||||
|
CurrentPrice: lc.CurrentPrice,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, &services.ComponentListResult{
|
||||||
|
Components: components,
|
||||||
|
Total: total,
|
||||||
|
Page: page,
|
||||||
|
PerPage: perPage,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, result)
|
c.JSON(http.StatusOK, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,36 @@ func (h *PricelistHandler) List(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If offline (empty list), fallback to local pricelists
|
||||||
|
if total == 0 && h.localDB != nil {
|
||||||
|
localPLs, err := h.localDB.GetLocalPricelists()
|
||||||
|
if err == nil && len(localPLs) > 0 {
|
||||||
|
// Convert to PricelistSummary format
|
||||||
|
summaries := make([]map[string]interface{}, len(localPLs))
|
||||||
|
for i, lpl := range localPLs {
|
||||||
|
summaries[i] = map[string]interface{}{
|
||||||
|
"id": lpl.ServerID,
|
||||||
|
"version": lpl.Version,
|
||||||
|
"created_by": "sync",
|
||||||
|
"item_count": 0, // Not tracked
|
||||||
|
"usage_count": 0, // Not tracked in local
|
||||||
|
"is_active": true,
|
||||||
|
"created_at": lpl.CreatedAt,
|
||||||
|
"synced_from": "local",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"pricelists": summaries,
|
||||||
|
"total": len(summaries),
|
||||||
|
"page": page,
|
||||||
|
"per_page": perPage,
|
||||||
|
"offline": true,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"pricelists": pricelists,
|
"pricelists": pricelists,
|
||||||
"total": total,
|
"total": total,
|
||||||
@@ -124,9 +154,33 @@ func (h *PricelistHandler) CanWrite(c *gin.Context) {
|
|||||||
|
|
||||||
// GetLatest returns the most recent active pricelist
|
// GetLatest returns the most recent active pricelist
|
||||||
func (h *PricelistHandler) GetLatest(c *gin.Context) {
|
func (h *PricelistHandler) GetLatest(c *gin.Context) {
|
||||||
|
// Try to get from server first
|
||||||
pl, err := h.service.GetLatestActive()
|
pl, err := h.service.GetLatestActive()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "no active pricelists found"})
|
// If offline or no server pricelists, try to get from local cache
|
||||||
|
if h.localDB == nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "no database available"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
localPL, localErr := h.localDB.GetLatestLocalPricelist()
|
||||||
|
if localErr != nil {
|
||||||
|
// No local pricelists either
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{
|
||||||
|
"error": "no pricelists available",
|
||||||
|
"local_error": localErr.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Return local pricelist
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"id": localPL.ServerID,
|
||||||
|
"version": localPL.Version,
|
||||||
|
"created_by": "sync",
|
||||||
|
"item_count": 0, // Not tracked in local pricelists
|
||||||
|
"is_active": true,
|
||||||
|
"created_at": localPL.CreatedAt,
|
||||||
|
"synced_from": "local",
|
||||||
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -68,6 +68,17 @@ func NewPricingHandler(
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *PricingHandler) GetStats(c *gin.Context) {
|
func (h *PricingHandler) GetStats(c *gin.Context) {
|
||||||
|
// Check if we're in offline mode
|
||||||
|
if h.statsRepo == nil || h.alertService == nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"new_alerts_count": 0,
|
||||||
|
"top_components": []interface{}{},
|
||||||
|
"trending_components": []interface{}{},
|
||||||
|
"offline": true,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
newAlerts, _ := h.alertService.GetNewAlertsCount()
|
newAlerts, _ := h.alertService.GetNewAlertsCount()
|
||||||
topComponents, _ := h.statsRepo.GetTopComponents(10)
|
topComponents, _ := h.statsRepo.GetTopComponents(10)
|
||||||
trendingComponents, _ := h.statsRepo.GetTrendingComponents(10)
|
trendingComponents, _ := h.statsRepo.GetTrendingComponents(10)
|
||||||
@@ -86,6 +97,19 @@ type ComponentWithCount struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *PricingHandler) ListComponents(c *gin.Context) {
|
func (h *PricingHandler) ListComponents(c *gin.Context) {
|
||||||
|
// Check if we're in offline mode
|
||||||
|
if h.componentRepo == nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"components": []ComponentWithCount{},
|
||||||
|
"total": 0,
|
||||||
|
"page": 1,
|
||||||
|
"per_page": 20,
|
||||||
|
"offline": true,
|
||||||
|
"message": "Управление ценами доступно только в онлайн режиме",
|
||||||
|
})
|
||||||
|
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"))
|
||||||
|
|
||||||
@@ -213,6 +237,15 @@ func (h *PricingHandler) expandMetaPrices(metaPrices, excludeLot string) []strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *PricingHandler) GetComponentPricing(c *gin.Context) {
|
func (h *PricingHandler) GetComponentPricing(c *gin.Context) {
|
||||||
|
// Check if we're in offline mode
|
||||||
|
if h.componentRepo == nil || h.pricingService == nil {
|
||||||
|
c.JSON(http.StatusServiceUnavailable, gin.H{
|
||||||
|
"error": "Управление ценами доступно только в онлайн режиме",
|
||||||
|
"offline": true,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
lotName := c.Param("lot_name")
|
lotName := c.Param("lot_name")
|
||||||
|
|
||||||
component, err := h.componentRepo.GetByLotName(lotName)
|
component, err := h.componentRepo.GetByLotName(lotName)
|
||||||
@@ -248,6 +281,15 @@ type UpdatePriceRequest struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *PricingHandler) UpdatePrice(c *gin.Context) {
|
func (h *PricingHandler) UpdatePrice(c *gin.Context) {
|
||||||
|
// Check if we're in offline mode
|
||||||
|
if h.db == nil {
|
||||||
|
c.JSON(http.StatusServiceUnavailable, gin.H{
|
||||||
|
"error": "Обновление цен доступно только в онлайн режиме",
|
||||||
|
"offline": true,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
var req UpdatePriceRequest
|
var req UpdatePriceRequest
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
@@ -409,6 +451,15 @@ func (h *PricingHandler) recalculateSinglePrice(lotName string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *PricingHandler) RecalculateAll(c *gin.Context) {
|
func (h *PricingHandler) RecalculateAll(c *gin.Context) {
|
||||||
|
// Check if we're in offline mode
|
||||||
|
if h.db == nil {
|
||||||
|
c.JSON(http.StatusServiceUnavailable, gin.H{
|
||||||
|
"error": "Пересчёт цен доступен только в онлайн режиме",
|
||||||
|
"offline": true,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Set headers for SSE
|
// Set headers for SSE
|
||||||
c.Header("Content-Type", "text/event-stream")
|
c.Header("Content-Type", "text/event-stream")
|
||||||
c.Header("Cache-Control", "no-cache")
|
c.Header("Cache-Control", "no-cache")
|
||||||
@@ -588,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"))
|
||||||
|
|
||||||
@@ -613,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"})
|
||||||
@@ -628,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"})
|
||||||
@@ -643,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"})
|
||||||
@@ -667,6 +757,15 @@ type PreviewPriceRequest struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *PricingHandler) PreviewPrice(c *gin.Context) {
|
func (h *PricingHandler) PreviewPrice(c *gin.Context) {
|
||||||
|
// Check if we're in offline mode
|
||||||
|
if h.db == nil {
|
||||||
|
c.JSON(http.StatusServiceUnavailable, gin.H{
|
||||||
|
"error": "Предпросмотр цены доступен только в онлайн режиме",
|
||||||
|
"offline": true,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
var req PreviewPriceRequest
|
var req PreviewPriceRequest
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
@@ -708,8 +807,8 @@ func (h *PricingHandler) PreviewPrice(c *gin.Context) {
|
|||||||
medianAllTime = &median
|
medianAllTime = &median
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get quote count (from all relevant lots)
|
// Get quote count (from all relevant lots) - total count
|
||||||
var quoteCount int64
|
var quoteCountTotal int64
|
||||||
for _, lotName := range lotNames {
|
for _, lotName := range lotNames {
|
||||||
var count int64
|
var count int64
|
||||||
if strings.HasSuffix(lotName, "*") {
|
if strings.HasSuffix(lotName, "*") {
|
||||||
@@ -718,7 +817,25 @@ func (h *PricingHandler) PreviewPrice(c *gin.Context) {
|
|||||||
} else {
|
} else {
|
||||||
h.db.Model(&models.LotLog{}).Where("lot = ?", lotName).Count(&count)
|
h.db.Model(&models.LotLog{}).Where("lot = ?", lotName).Count(&count)
|
||||||
}
|
}
|
||||||
quoteCount += count
|
quoteCountTotal += count
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get quote count for specified period (if period is > 0)
|
||||||
|
var quoteCountPeriod int64
|
||||||
|
if req.PeriodDays > 0 {
|
||||||
|
for _, lotName := range lotNames {
|
||||||
|
var count int64
|
||||||
|
if strings.HasSuffix(lotName, "*") {
|
||||||
|
pattern := strings.TrimSuffix(lotName, "*") + "%"
|
||||||
|
h.db.Raw(`SELECT COUNT(*) FROM lot_log WHERE lot LIKE ? AND date >= DATE_SUB(NOW(), INTERVAL ? DAY)`, pattern, req.PeriodDays).Scan(&count)
|
||||||
|
} else {
|
||||||
|
h.db.Raw(`SELECT COUNT(*) FROM lot_log WHERE lot = ? AND date >= DATE_SUB(NOW(), INTERVAL ? DAY)`, lotName, req.PeriodDays).Scan(&count)
|
||||||
|
}
|
||||||
|
quoteCountPeriod += count
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// If no period specified, period count equals total count
|
||||||
|
quoteCountPeriod = quoteCountTotal
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get last received price (from the main lot only)
|
// Get last received price (from the main lot only)
|
||||||
@@ -773,14 +890,15 @@ func (h *PricingHandler) PreviewPrice(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"lot_name": req.LotName,
|
"lot_name": req.LotName,
|
||||||
"current_price": comp.CurrentPrice,
|
"current_price": comp.CurrentPrice,
|
||||||
"median_all_time": medianAllTime,
|
"median_all_time": medianAllTime,
|
||||||
"new_price": newPrice,
|
"new_price": newPrice,
|
||||||
"quote_count": quoteCount,
|
"quote_count_total": quoteCountTotal,
|
||||||
"manual_price": comp.ManualPrice,
|
"quote_count_period": quoteCountPeriod,
|
||||||
"last_price": lastPrice.Price,
|
"manual_price": comp.ManualPrice,
|
||||||
"last_price_date": lastPrice.Date,
|
"last_price": lastPrice.Price,
|
||||||
|
"last_price_date": lastPrice.Date,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,28 +4,36 @@ 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/localdb"
|
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/services/sync"
|
"git.mchus.pro/mchus/quoteforge/internal/services/sync"
|
||||||
"gorm.io/gorm"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
// SyncHandler handles sync API endpoints
|
// SyncHandler handles sync API endpoints
|
||||||
type SyncHandler struct {
|
type SyncHandler struct {
|
||||||
localDB *localdb.LocalDB
|
localDB *localdb.LocalDB
|
||||||
syncService *sync.Service
|
syncService *sync.Service
|
||||||
mariaDB *gorm.DB
|
connMgr *db.ConnectionManager
|
||||||
tmpl *template.Template
|
tmpl *template.Template
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewSyncHandler creates a new sync handler
|
// NewSyncHandler creates a new sync handler
|
||||||
func NewSyncHandler(localDB *localdb.LocalDB, syncService *sync.Service, mariaDB *gorm.DB, 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
|
||||||
}
|
}
|
||||||
@@ -33,21 +41,21 @@ func NewSyncHandler(localDB *localdb.LocalDB, syncService *sync.Service, mariaDB
|
|||||||
return &SyncHandler{
|
return &SyncHandler{
|
||||||
localDB: localDB,
|
localDB: localDB,
|
||||||
syncService: syncService,
|
syncService: syncService,
|
||||||
mariaDB: mariaDB,
|
connMgr: connMgr,
|
||||||
tmpl: tmpl,
|
tmpl: tmpl,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SyncStatusResponse represents the sync status
|
// SyncStatusResponse represents the sync status
|
||||||
type SyncStatusResponse struct {
|
type SyncStatusResponse struct {
|
||||||
LastComponentSync *time.Time `json:"last_component_sync"`
|
LastComponentSync *time.Time `json:"last_component_sync"`
|
||||||
LastPricelistSync *time.Time `json:"last_pricelist_sync"`
|
LastPricelistSync *time.Time `json:"last_pricelist_sync"`
|
||||||
IsOnline bool `json:"is_online"`
|
IsOnline bool `json:"is_online"`
|
||||||
ComponentsCount int64 `json:"components_count"`
|
ComponentsCount int64 `json:"components_count"`
|
||||||
PricelistsCount int64 `json:"pricelists_count"`
|
PricelistsCount int64 `json:"pricelists_count"`
|
||||||
ServerPricelists int `json:"server_pricelists"`
|
ServerPricelists int `json:"server_pricelists"`
|
||||||
NeedComponentSync bool `json:"need_component_sync"`
|
NeedComponentSync bool `json:"need_component_sync"`
|
||||||
NeedPricelistSync bool `json:"need_pricelist_sync"`
|
NeedPricelistSync bool `json:"need_pricelist_sync"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetStatus returns current sync status
|
// GetStatus returns current sync status
|
||||||
@@ -79,14 +87,14 @@ func (h *SyncHandler) GetStatus(c *gin.Context) {
|
|||||||
needComponentSync := h.localDB.NeedComponentSync(24)
|
needComponentSync := h.localDB.NeedComponentSync(24)
|
||||||
|
|
||||||
c.JSON(http.StatusOK, SyncStatusResponse{
|
c.JSON(http.StatusOK, SyncStatusResponse{
|
||||||
LastComponentSync: lastComponentSync,
|
LastComponentSync: lastComponentSync,
|
||||||
LastPricelistSync: lastPricelistSync,
|
LastPricelistSync: lastPricelistSync,
|
||||||
IsOnline: isOnline,
|
IsOnline: isOnline,
|
||||||
ComponentsCount: componentsCount,
|
ComponentsCount: componentsCount,
|
||||||
PricelistsCount: pricelistsCount,
|
PricelistsCount: pricelistsCount,
|
||||||
ServerPricelists: serverPricelists,
|
ServerPricelists: serverPricelists,
|
||||||
NeedComponentSync: needComponentSync,
|
NeedComponentSync: needComponentSync,
|
||||||
NeedPricelistSync: needPricelistSync,
|
NeedPricelistSync: needPricelistSync,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,7 +117,17 @@ func (h *SyncHandler) SyncComponents(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := h.localDB.SyncComponents(h.mariaDB)
|
// Get database connection from ConnectionManager
|
||||||
|
mariaDB, err := h.connMgr.GetDB()
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusServiceUnavailable, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"error": "Database connection failed: " + err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := h.localDB.SyncComponents(mariaDB)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("component sync failed", "error", err)
|
slog.Error("component sync failed", "error", err)
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
@@ -159,11 +177,11 @@ func (h *SyncHandler) SyncPricelists(c *gin.Context) {
|
|||||||
|
|
||||||
// SyncAllResponse represents result of full sync
|
// SyncAllResponse represents result of full sync
|
||||||
type SyncAllResponse struct {
|
type SyncAllResponse struct {
|
||||||
Success bool `json:"success"`
|
Success bool `json:"success"`
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
ComponentsSynced int `json:"components_synced"`
|
ComponentsSynced int `json:"components_synced"`
|
||||||
PricelistsSynced int `json:"pricelists_synced"`
|
PricelistsSynced int `json:"pricelists_synced"`
|
||||||
Duration string `json:"duration"`
|
Duration string `json:"duration"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// SyncAll syncs both components and pricelists
|
// SyncAll syncs both components and pricelists
|
||||||
@@ -181,7 +199,16 @@ func (h *SyncHandler) SyncAll(c *gin.Context) {
|
|||||||
var componentsSynced, pricelistsSynced int
|
var componentsSynced, pricelistsSynced int
|
||||||
|
|
||||||
// Sync components
|
// Sync components
|
||||||
compResult, err := h.localDB.SyncComponents(h.mariaDB)
|
mariaDB, err := h.connMgr.GetDB()
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusServiceUnavailable, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"error": "Database connection failed: " + err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
compResult, err := h.localDB.SyncComponents(mariaDB)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("component sync failed during full sync", "error", err)
|
slog.Error("component sync failed during full sync", "error", err)
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
@@ -197,8 +224,8 @@ func (h *SyncHandler) SyncAll(c *gin.Context) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("pricelist sync failed during full sync", "error", err)
|
slog.Error("pricelist sync failed during full sync", "error", err)
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
"success": false,
|
"success": false,
|
||||||
"error": "Pricelist sync failed: " + err.Error(),
|
"error": "Pricelist sync failed: " + err.Error(),
|
||||||
"components_synced": componentsSynced,
|
"components_synced": componentsSynced,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
@@ -215,16 +242,7 @@ func (h *SyncHandler) SyncAll(c *gin.Context) {
|
|||||||
|
|
||||||
// checkOnline checks if MariaDB is accessible
|
// checkOnline checks if MariaDB is accessible
|
||||||
func (h *SyncHandler) checkOnline() bool {
|
func (h *SyncHandler) checkOnline() bool {
|
||||||
sqlDB, err := h.mariaDB.DB()
|
return h.connMgr.IsOnline()
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := sqlDB.Ping(); err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// PushPendingChanges pushes all pending changes to the server
|
// PushPendingChanges pushes all pending changes to the server
|
||||||
@@ -282,6 +300,70 @@ func (h *SyncHandler) GetPendingChanges(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SyncInfoResponse represents sync information
|
||||||
|
type SyncInfoResponse struct {
|
||||||
|
LastSyncAt *time.Time `json:"last_sync_at"`
|
||||||
|
IsOnline bool `json:"is_online"`
|
||||||
|
ErrorCount int `json:"error_count"`
|
||||||
|
Errors []SyncError `json:"errors,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncError represents a sync error
|
||||||
|
type SyncError struct {
|
||||||
|
Timestamp time.Time `json:"timestamp"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetInfo returns sync information for modal
|
||||||
|
// GET /api/sync/info
|
||||||
|
func (h *SyncHandler) GetInfo(c *gin.Context) {
|
||||||
|
// Check online status by pinging MariaDB
|
||||||
|
isOnline := h.checkOnline()
|
||||||
|
|
||||||
|
// Get sync times
|
||||||
|
lastPricelistSync := h.localDB.GetLastSyncTime()
|
||||||
|
|
||||||
|
// Get error count (only changes with LastError != "")
|
||||||
|
errorCount := int(h.localDB.CountErroredChanges())
|
||||||
|
|
||||||
|
// Get recent errors (last 10)
|
||||||
|
changes, err := h.localDB.GetPendingChanges()
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("failed to get pending changes for sync info", "error", err)
|
||||||
|
// Even if we can't get changes, we can still return the error count
|
||||||
|
c.JSON(http.StatusOK, SyncInfoResponse{
|
||||||
|
LastSyncAt: lastPricelistSync,
|
||||||
|
IsOnline: isOnline,
|
||||||
|
ErrorCount: errorCount,
|
||||||
|
Errors: []SyncError{}, // Return empty errors list
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var errors []SyncError
|
||||||
|
for _, change := range changes {
|
||||||
|
// Check if there's a last error and it's not empty
|
||||||
|
if change.LastError != "" {
|
||||||
|
errors = append(errors, SyncError{
|
||||||
|
Timestamp: change.CreatedAt,
|
||||||
|
Message: change.LastError,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limit to last 10 errors
|
||||||
|
if len(errors) > 10 {
|
||||||
|
errors = errors[:10]
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, SyncInfoResponse{
|
||||||
|
LastSyncAt: lastPricelistSync,
|
||||||
|
IsOnline: isOnline,
|
||||||
|
ErrorCount: errorCount,
|
||||||
|
Errors: errors,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// SyncStatusPartial renders the sync status partial for htmx
|
// SyncStatusPartial renders the sync status partial for htmx
|
||||||
// GET /partials/sync-status
|
// GET /partials/sync-status
|
||||||
func (h *SyncHandler) SyncStatusPartial(c *gin.Context) {
|
func (h *SyncHandler) SyncStatusPartial(c *gin.Context) {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,13 @@ import (
|
|||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ComponentFilter for searching with filters
|
||||||
|
type ComponentFilter struct {
|
||||||
|
Category string
|
||||||
|
Search string
|
||||||
|
HasPrice bool
|
||||||
|
}
|
||||||
|
|
||||||
// ComponentSyncResult contains statistics from component sync
|
// ComponentSyncResult contains statistics from component sync
|
||||||
type ComponentSyncResult struct {
|
type ComponentSyncResult struct {
|
||||||
TotalSynced int
|
TotalSynced int
|
||||||
@@ -196,6 +203,44 @@ func (l *LocalDB) SearchLocalComponentsByCategory(category string, query string,
|
|||||||
return components, err
|
return components, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListComponents returns components with filtering and pagination
|
||||||
|
func (l *LocalDB) ListComponents(filter ComponentFilter, offset, limit int) ([]LocalComponent, int64, error) {
|
||||||
|
db := l.db
|
||||||
|
|
||||||
|
// Apply category filter
|
||||||
|
if filter.Category != "" {
|
||||||
|
db = db.Where("LOWER(category) = ?", strings.ToLower(filter.Category))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply search filter
|
||||||
|
if filter.Search != "" {
|
||||||
|
searchPattern := "%" + strings.ToLower(filter.Search) + "%"
|
||||||
|
db = db.Where(
|
||||||
|
"LOWER(lot_name) LIKE ? OR LOWER(lot_description) LIKE ? OR LOWER(category) LIKE ? OR LOWER(model) LIKE ?",
|
||||||
|
searchPattern, searchPattern, searchPattern, searchPattern,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply price filter
|
||||||
|
if filter.HasPrice {
|
||||||
|
db = db.Where("current_price IS NOT NULL")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get total count
|
||||||
|
var total int64
|
||||||
|
if err := db.Model(&LocalComponent{}).Count(&total).Error; err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply pagination and get results
|
||||||
|
var components []LocalComponent
|
||||||
|
if err := db.Order("lot_name").Offset(offset).Limit(limit).Find(&components).Error; err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return components, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
// GetLocalComponent returns a single component by lot_name
|
// GetLocalComponent returns a single component by lot_name
|
||||||
func (l *LocalDB) GetLocalComponent(lotName string) (*LocalComponent, error) {
|
func (l *LocalDB) GetLocalComponent(lotName string) (*LocalComponent, error) {
|
||||||
var component LocalComponent
|
var component LocalComponent
|
||||||
@@ -266,3 +311,100 @@ func (l *LocalDB) NeedComponentSync(maxAgeHours int) bool {
|
|||||||
}
|
}
|
||||||
return time.Since(*syncTime).Hours() > float64(maxAgeHours)
|
return time.Since(*syncTime).Hours() > float64(maxAgeHours)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdateComponentPricesFromPricelist updates current_price in local_components from pricelist items
|
||||||
|
// This allows offline price updates using synced pricelists without MariaDB connection
|
||||||
|
func (l *LocalDB) UpdateComponentPricesFromPricelist(pricelistID uint) (int, error) {
|
||||||
|
// Get all items from the specified pricelist
|
||||||
|
var items []LocalPricelistItem
|
||||||
|
if err := l.db.Where("pricelist_id = ?", pricelistID).Find(&items).Error; err != nil {
|
||||||
|
return 0, fmt.Errorf("fetching pricelist items: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(items) == 0 {
|
||||||
|
slog.Warn("no items found in pricelist", "pricelist_id", pricelistID)
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update current_price for each component
|
||||||
|
updated := 0
|
||||||
|
err := l.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
for _, item := range items {
|
||||||
|
result := tx.Model(&LocalComponent{}).
|
||||||
|
Where("lot_name = ?", item.LotName).
|
||||||
|
Update("current_price", item.Price)
|
||||||
|
|
||||||
|
if result.Error != nil {
|
||||||
|
return fmt.Errorf("updating price for %s: %w", item.LotName, result.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.RowsAffected > 0 {
|
||||||
|
updated++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("updated component prices from pricelist",
|
||||||
|
"pricelist_id", pricelistID,
|
||||||
|
"total_items", len(items),
|
||||||
|
"updated_components", updated)
|
||||||
|
|
||||||
|
return updated, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureComponentPricesFromPricelists loads prices from the latest pricelist into local_components
|
||||||
|
// if no components exist or all current prices are NULL
|
||||||
|
func (l *LocalDB) EnsureComponentPricesFromPricelists() error {
|
||||||
|
// Check if we have any components with prices
|
||||||
|
var count int64
|
||||||
|
if err := l.db.Model(&LocalComponent{}).Where("current_price IS NOT NULL").Count(&count).Error; err != nil {
|
||||||
|
return fmt.Errorf("checking component prices: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we have components with prices, don't load from pricelists
|
||||||
|
if count > 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we have any components at all
|
||||||
|
var totalComponents int64
|
||||||
|
if err := l.db.Model(&LocalComponent{}).Count(&totalComponents).Error; err != nil {
|
||||||
|
return fmt.Errorf("counting components: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we have no components, we need to load them from pricelists
|
||||||
|
if totalComponents == 0 {
|
||||||
|
slog.Info("no components found in local database, loading from latest pricelist")
|
||||||
|
// This would typically be called from the sync service or setup process
|
||||||
|
// For now, we'll just return nil to indicate no action needed
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we have components but no prices, we should load prices from pricelists
|
||||||
|
// Find the latest pricelist
|
||||||
|
var latestPricelist LocalPricelist
|
||||||
|
if err := l.db.Order("created_at DESC").First(&latestPricelist).Error; err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
slog.Warn("no pricelists found in local database")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("finding latest pricelist: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update prices from the latest pricelist
|
||||||
|
updated, err := l.UpdateComponentPricesFromPricelist(latestPricelist.ID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("updating component prices from pricelist: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("loaded component prices from latest pricelist",
|
||||||
|
"pricelist_id", latestPricelist.ID,
|
||||||
|
"updated_components", updated)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -132,7 +132,11 @@ func (l *LocalDB) GetDSN() (string, error) {
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local",
|
// Add aggressive timeouts for offline-first architecture
|
||||||
|
// timeout: connection establishment timeout (3s)
|
||||||
|
// readTimeout: I/O read timeout (3s)
|
||||||
|
// writeTimeout: I/O write timeout (3s)
|
||||||
|
dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local&timeout=3s&readTimeout=3s&writeTimeout=3s",
|
||||||
settings.User,
|
settings.User,
|
||||||
settings.PasswordEncrypted, // Contains decrypted password after GetSettings
|
settings.PasswordEncrypted, // Contains decrypted password after GetSettings
|
||||||
settings.Host,
|
settings.Host,
|
||||||
@@ -396,6 +400,13 @@ func (l *LocalDB) CountPendingChangesByType(entityType string) int64 {
|
|||||||
return count
|
return count
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CountErroredChanges returns the number of pending changes with errors
|
||||||
|
func (l *LocalDB) CountErroredChanges() int64 {
|
||||||
|
var count int64
|
||||||
|
l.db.Model(&PendingChange{}).Where("last_error != ?", "").Count(&count)
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
// MarkChangesSynced marks multiple pending changes as synced by deleting them
|
// MarkChangesSynced marks multiple pending changes as synced by deleting them
|
||||||
func (l *LocalDB) MarkChangesSynced(ids []int64) error {
|
func (l *LocalDB) MarkChangesSynced(ids []int64) error {
|
||||||
if len(ids) == 0 {
|
if len(ids) == 0 {
|
||||||
|
|||||||
@@ -4,17 +4,17 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/db"
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// OfflineDetector creates middleware that detects offline mode
|
// OfflineDetector creates middleware that detects offline mode
|
||||||
// Sets context values:
|
// Sets context values:
|
||||||
// - "is_offline" (bool) - true if MariaDB is unavailable
|
// - "is_offline" (bool) - true if MariaDB is unavailable
|
||||||
// - "localdb" (*localdb.LocalDB) - local database instance for fallback
|
// - "localdb" (*localdb.LocalDB) - local database instance for fallback
|
||||||
func OfflineDetector(mariaDB *gorm.DB, local *localdb.LocalDB) gin.HandlerFunc {
|
func OfflineDetector(connMgr *db.ConnectionManager, local *localdb.LocalDB) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
isOffline := !checkMariaDBOnline(mariaDB)
|
isOffline := !connMgr.IsOnline()
|
||||||
|
|
||||||
// Set context values for handlers
|
// Set context values for handlers
|
||||||
c.Set("is_offline", isOffline)
|
c.Set("is_offline", isOffline)
|
||||||
@@ -27,17 +27,3 @@ func OfflineDetector(mariaDB *gorm.DB, local *localdb.LocalDB) gin.HandlerFunc {
|
|||||||
c.Next()
|
c.Next()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkMariaDBOnline checks if MariaDB is accessible
|
|
||||||
func checkMariaDBOnline(mariaDB *gorm.DB) bool {
|
|
||||||
sqlDB, err := mariaDB.DB()
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := sqlDB.Ping(); err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package services
|
package services
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||||
@@ -59,6 +60,17 @@ type ComponentView struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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 no database connection (offline mode), return empty list
|
||||||
|
// Components should be loaded via /api/sync/components first
|
||||||
|
if s.componentRepo == nil {
|
||||||
|
return &ComponentListResult{
|
||||||
|
Components: []ComponentView{},
|
||||||
|
Total: 0,
|
||||||
|
Page: page,
|
||||||
|
PerPage: perPage,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
if page < 1 {
|
if page < 1 {
|
||||||
page = 1
|
page = 1
|
||||||
}
|
}
|
||||||
@@ -106,6 +118,11 @@ func (s *ComponentService) List(filter repository.ComponentFilter, page, perPage
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *ComponentService) GetByLotName(lotName string) (*ComponentView, error) {
|
func (s *ComponentService) GetByLotName(lotName string) (*ComponentView, error) {
|
||||||
|
// If no database connection (offline mode), return error
|
||||||
|
if s.componentRepo == nil {
|
||||||
|
return nil, fmt.Errorf("offline mode: component data not available")
|
||||||
|
}
|
||||||
|
|
||||||
c, err := s.componentRepo.GetByLotName(lotName)
|
c, err := s.componentRepo.GetByLotName(lotName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -135,11 +152,20 @@ func (s *ComponentService) GetByLotName(lotName string) (*ComponentView, error)
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *ComponentService) GetCategories() ([]models.Category, error) {
|
func (s *ComponentService) GetCategories() ([]models.Category, error) {
|
||||||
|
// If no database connection (offline mode), return default categories
|
||||||
|
if s.categoryRepo == nil {
|
||||||
|
return models.DefaultCategories, nil
|
||||||
|
}
|
||||||
return s.categoryRepo.GetAll()
|
return s.categoryRepo.GetAll()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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) {
|
||||||
|
// If no database connection (offline mode), return error
|
||||||
|
if s.componentRepo == nil || s.categoryRepo == nil {
|
||||||
|
return 0, fmt.Errorf("offline mode: import not available")
|
||||||
|
}
|
||||||
|
|
||||||
lots, err := s.componentRepo.GetLotsWithoutMetadata()
|
lots, err := s.componentRepo.GetLotsWithoutMetadata()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ func NewService(db *gorm.DB, repo *repository.PricelistRepository, componentRepo
|
|||||||
|
|
||||||
// CreateFromCurrentPrices creates a new pricelist by taking a snapshot of current prices
|
// CreateFromCurrentPrices creates a new pricelist by taking a snapshot of current prices
|
||||||
func (s *Service) CreateFromCurrentPrices(createdBy string) (*models.Pricelist, error) {
|
func (s *Service) CreateFromCurrentPrices(createdBy string) (*models.Pricelist, error) {
|
||||||
|
if s.repo == nil || s.db == nil {
|
||||||
|
return nil, fmt.Errorf("offline mode: cannot create pricelists")
|
||||||
|
}
|
||||||
|
|
||||||
version, err := s.repo.GenerateVersion()
|
version, err := s.repo.GenerateVersion()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("generating version: %w", err)
|
return nil, fmt.Errorf("generating version: %w", err)
|
||||||
@@ -88,6 +92,11 @@ func (s *Service) CreateFromCurrentPrices(createdBy string) (*models.Pricelist,
|
|||||||
|
|
||||||
// List returns pricelists with pagination
|
// List returns pricelists with pagination
|
||||||
func (s *Service) List(page, perPage int) ([]models.PricelistSummary, int64, error) {
|
func (s *Service) List(page, perPage int) ([]models.PricelistSummary, int64, error) {
|
||||||
|
// If no database connection (offline mode), return empty list
|
||||||
|
if s.repo == nil {
|
||||||
|
return []models.PricelistSummary{}, 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
if page < 1 {
|
if page < 1 {
|
||||||
page = 1
|
page = 1
|
||||||
}
|
}
|
||||||
@@ -100,11 +109,17 @@ func (s *Service) List(page, perPage int) ([]models.PricelistSummary, int64, err
|
|||||||
|
|
||||||
// GetByID returns a pricelist by ID
|
// GetByID returns a pricelist by ID
|
||||||
func (s *Service) GetByID(id uint) (*models.Pricelist, error) {
|
func (s *Service) GetByID(id uint) (*models.Pricelist, error) {
|
||||||
|
if s.repo == nil {
|
||||||
|
return nil, fmt.Errorf("offline mode: pricelist service not available")
|
||||||
|
}
|
||||||
return s.repo.GetByID(id)
|
return s.repo.GetByID(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetItems returns pricelist items with pagination
|
// GetItems returns pricelist items with pagination
|
||||||
func (s *Service) GetItems(pricelistID uint, page, perPage int, search string) ([]models.PricelistItem, int64, error) {
|
func (s *Service) GetItems(pricelistID uint, page, perPage int, search string) ([]models.PricelistItem, int64, error) {
|
||||||
|
if s.repo == nil {
|
||||||
|
return []models.PricelistItem{}, 0, nil
|
||||||
|
}
|
||||||
if page < 1 {
|
if page < 1 {
|
||||||
page = 1
|
page = 1
|
||||||
}
|
}
|
||||||
@@ -117,26 +132,42 @@ func (s *Service) GetItems(pricelistID uint, page, perPage int, search string) (
|
|||||||
|
|
||||||
// Delete deletes a pricelist by ID
|
// Delete deletes a pricelist by ID
|
||||||
func (s *Service) Delete(id uint) error {
|
func (s *Service) Delete(id uint) error {
|
||||||
|
if s.repo == nil {
|
||||||
|
return fmt.Errorf("offline mode: cannot delete pricelists")
|
||||||
|
}
|
||||||
return s.repo.Delete(id)
|
return s.repo.Delete(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
// CanWrite returns true if the user can create pricelists
|
// CanWrite returns true if the user can create pricelists
|
||||||
func (s *Service) CanWrite() bool {
|
func (s *Service) CanWrite() bool {
|
||||||
|
if s.repo == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
return s.repo.CanWrite()
|
return s.repo.CanWrite()
|
||||||
}
|
}
|
||||||
|
|
||||||
// CanWriteDebug returns write permission status with debug info
|
// CanWriteDebug returns write permission status with debug info
|
||||||
func (s *Service) CanWriteDebug() (bool, string) {
|
func (s *Service) CanWriteDebug() (bool, string) {
|
||||||
|
if s.repo == nil {
|
||||||
|
return false, "offline mode"
|
||||||
|
}
|
||||||
return s.repo.CanWriteDebug()
|
return s.repo.CanWriteDebug()
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetLatestActive returns the most recent active pricelist
|
// GetLatestActive returns the most recent active pricelist
|
||||||
func (s *Service) GetLatestActive() (*models.Pricelist, error) {
|
func (s *Service) GetLatestActive() (*models.Pricelist, error) {
|
||||||
|
if s.repo == nil {
|
||||||
|
return nil, fmt.Errorf("offline mode: pricelist service not available")
|
||||||
|
}
|
||||||
return s.repo.GetLatestActive()
|
return s.repo.GetLatestActive()
|
||||||
}
|
}
|
||||||
|
|
||||||
// CleanupExpired deletes expired and unused pricelists
|
// CleanupExpired deletes expired and unused pricelists
|
||||||
func (s *Service) CleanupExpired() (int, error) {
|
func (s *Service) CleanupExpired() (int, error) {
|
||||||
|
if s.repo == nil {
|
||||||
|
return 0, fmt.Errorf("offline mode: cleanup not available")
|
||||||
|
}
|
||||||
|
|
||||||
expired, err := s.repo.GetExpiredUnused()
|
expired, err := s.repo.GetExpiredUnused()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, 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:
|
||||||
@@ -149,17 +149,17 @@ func (s *Service) GetPriceStats(lotName string, periodDays int) (*PriceStats, er
|
|||||||
}
|
}
|
||||||
|
|
||||||
return &PriceStats{
|
return &PriceStats{
|
||||||
QuoteCount: len(points),
|
QuoteCount: len(points),
|
||||||
MinPrice: CalculatePercentile(prices, 0),
|
MinPrice: CalculatePercentile(prices, 0),
|
||||||
MaxPrice: CalculatePercentile(prices, 100),
|
MaxPrice: CalculatePercentile(prices, 100),
|
||||||
MedianPrice: CalculateMedian(prices),
|
MedianPrice: CalculateMedian(prices),
|
||||||
AveragePrice: CalculateAverage(prices),
|
AveragePrice: CalculateAverage(prices),
|
||||||
StdDeviation: CalculateStdDev(prices),
|
StdDeviation: CalculateStdDev(prices),
|
||||||
LatestPrice: points[0].Price,
|
LatestPrice: points[0].Price,
|
||||||
LatestDate: points[0].Date,
|
LatestDate: points[0].Date,
|
||||||
OldestDate: points[len(points)-1].Date,
|
OldestDate: points[len(points)-1].Date,
|
||||||
Percentile25: CalculatePercentile(prices, 25),
|
Percentile25: CalculatePercentile(prices, 25),
|
||||||
Percentile75: CalculatePercentile(prices, 75),
|
Percentile75: CalculatePercentile(prices, 75),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,14 +9,14 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
ErrEmptyQuote = errors.New("quote cannot be empty")
|
ErrEmptyQuote = errors.New("quote cannot be empty")
|
||||||
ErrComponentNotFound = errors.New("component not found")
|
ErrComponentNotFound = errors.New("component not found")
|
||||||
ErrNoPriceAvailable = errors.New("no price available for component")
|
ErrNoPriceAvailable = errors.New("no price available for component")
|
||||||
)
|
)
|
||||||
|
|
||||||
type QuoteService struct {
|
type QuoteService struct {
|
||||||
componentRepo *repository.ComponentRepository
|
componentRepo *repository.ComponentRepository
|
||||||
statsRepo *repository.StatsRepository
|
statsRepo *repository.StatsRepository
|
||||||
pricingService *pricing.Service
|
pricingService *pricing.Service
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,11 +43,11 @@ type QuoteItem struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type QuoteValidationResult struct {
|
type QuoteValidationResult struct {
|
||||||
Valid bool `json:"valid"`
|
Valid bool `json:"valid"`
|
||||||
Items []QuoteItem `json:"items"`
|
Items []QuoteItem `json:"items"`
|
||||||
Errors []string `json:"errors"`
|
Errors []string `json:"errors"`
|
||||||
Warnings []string `json:"warnings"`
|
Warnings []string `json:"warnings"`
|
||||||
Total float64 `json:"total"`
|
Total float64 `json:"total"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type QuoteRequest struct {
|
type QuoteRequest struct {
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"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/models"
|
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
||||||
@@ -13,17 +14,15 @@ import (
|
|||||||
|
|
||||||
// Service handles synchronization between MariaDB and local SQLite
|
// Service handles synchronization between MariaDB and local SQLite
|
||||||
type Service struct {
|
type Service struct {
|
||||||
pricelistRepo *repository.PricelistRepository
|
connMgr *db.ConnectionManager
|
||||||
configRepo *repository.ConfigurationRepository
|
localDB *localdb.LocalDB
|
||||||
localDB *localdb.LocalDB
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewService creates a new sync service
|
// NewService creates a new sync service
|
||||||
func NewService(pricelistRepo *repository.PricelistRepository, configRepo *repository.ConfigurationRepository, localDB *localdb.LocalDB) *Service {
|
func NewService(connMgr *db.ConnectionManager, localDB *localdb.LocalDB) *Service {
|
||||||
return &Service{
|
return &Service{
|
||||||
pricelistRepo: pricelistRepo,
|
connMgr: connMgr,
|
||||||
configRepo: configRepo,
|
localDB: localDB,
|
||||||
localDB: localDB,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,10 +38,17 @@ type SyncStatus struct {
|
|||||||
func (s *Service) GetStatus() (*SyncStatus, error) {
|
func (s *Service) GetStatus() (*SyncStatus, error) {
|
||||||
lastSync := s.localDB.GetLastSyncTime()
|
lastSync := s.localDB.GetLastSyncTime()
|
||||||
|
|
||||||
// Count server pricelists
|
// Count server pricelists (only if already connected, don't reconnect)
|
||||||
serverPricelists, _, err := s.pricelistRepo.List(0, 1)
|
serverCount := 0
|
||||||
if err != nil {
|
connStatus := s.connMgr.GetStatus()
|
||||||
return nil, fmt.Errorf("counting server pricelists: %w", err)
|
if connStatus.IsConnected {
|
||||||
|
if mariaDB, err := s.connMgr.GetDB(); err == nil && mariaDB != nil {
|
||||||
|
pricelistRepo := repository.NewPricelistRepository(mariaDB)
|
||||||
|
serverPricelists, _, err := pricelistRepo.List(0, 1)
|
||||||
|
if err == nil {
|
||||||
|
serverCount = len(serverPricelists)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Count local pricelists
|
// Count local pricelists
|
||||||
@@ -52,7 +58,7 @@ func (s *Service) GetStatus() (*SyncStatus, error) {
|
|||||||
|
|
||||||
return &SyncStatus{
|
return &SyncStatus{
|
||||||
LastSyncAt: lastSync,
|
LastSyncAt: lastSync,
|
||||||
ServerPricelists: len(serverPricelists),
|
ServerPricelists: serverCount,
|
||||||
LocalPricelists: int(localCount),
|
LocalPricelists: int(localCount),
|
||||||
NeedsSync: needsSync,
|
NeedsSync: needsSync,
|
||||||
}, nil
|
}, nil
|
||||||
@@ -73,8 +79,21 @@ func (s *Service) NeedSync() (bool, error) {
|
|||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if there are new pricelists on server
|
// Check if there are new pricelists on server (only if already connected)
|
||||||
latestServer, err := s.pricelistRepo.GetLatestActive()
|
connStatus := s.connMgr.GetStatus()
|
||||||
|
if !connStatus.IsConnected {
|
||||||
|
// If offline, can't check server, no need to sync
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
mariaDB, err := s.connMgr.GetDB()
|
||||||
|
if err != nil {
|
||||||
|
// If offline, can't check server, no need to sync
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
pricelistRepo := repository.NewPricelistRepository(mariaDB)
|
||||||
|
latestServer, err := pricelistRepo.GetLatestActive()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// If no pricelists on server, no need to sync
|
// If no pricelists on server, no need to sync
|
||||||
return false, nil
|
return false, nil
|
||||||
@@ -98,18 +117,33 @@ func (s *Service) NeedSync() (bool, error) {
|
|||||||
func (s *Service) SyncPricelists() (int, error) {
|
func (s *Service) SyncPricelists() (int, error) {
|
||||||
slog.Info("starting pricelist sync")
|
slog.Info("starting pricelist sync")
|
||||||
|
|
||||||
|
// Get database connection
|
||||||
|
mariaDB, err := s.connMgr.GetDB()
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("database not available: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create repository
|
||||||
|
pricelistRepo := repository.NewPricelistRepository(mariaDB)
|
||||||
|
|
||||||
// Get all active pricelists from server (up to 100)
|
// Get all active pricelists from server (up to 100)
|
||||||
serverPricelists, _, err := s.pricelistRepo.List(0, 100)
|
serverPricelists, _, err := pricelistRepo.List(0, 100)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("getting server pricelists: %w", err)
|
return 0, fmt.Errorf("getting server pricelists: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
synced := 0
|
synced := 0
|
||||||
|
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, skip
|
// Already synced, track latest by server ID
|
||||||
|
if pl.ID > latestServerID {
|
||||||
|
latestServerID = pl.ID
|
||||||
|
latestLocalID = existing.ID
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,8 +162,30 @@ func (s *Service) SyncPricelists() (int, error) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sync items for the newly created pricelist
|
||||||
|
itemCount, err := s.SyncPricelistItems(localPL.ID)
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("failed to sync pricelist items", "version", pl.Version, "error", err)
|
||||||
|
// Continue even if items sync fails - we have the pricelist metadata
|
||||||
|
} else {
|
||||||
|
slog.Debug("synced pricelist with items", "version", pl.Version, "items", itemCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
if pl.ID > latestServerID {
|
||||||
|
latestServerID = pl.ID
|
||||||
|
latestLocalID = localPL.ID
|
||||||
|
}
|
||||||
synced++
|
synced++
|
||||||
slog.Debug("synced pricelist", "version", pl.Version, "server_id", pl.ID)
|
}
|
||||||
|
|
||||||
|
// Update component prices from latest pricelist
|
||||||
|
if latestLocalID > 0 {
|
||||||
|
updated, err := s.localDB.UpdateComponentPricesFromPricelist(latestLocalID)
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("failed to update component prices from pricelist", "error", err)
|
||||||
|
} else {
|
||||||
|
slog.Info("updated component prices from latest pricelist", "updated", updated)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update last sync time
|
// Update last sync time
|
||||||
@@ -154,8 +210,17 @@ func (s *Service) SyncPricelistItems(localPricelistID uint) (int, error) {
|
|||||||
return int(existingCount), nil
|
return int(existingCount), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get database connection
|
||||||
|
mariaDB, err := s.connMgr.GetDB()
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("database not available: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create repository
|
||||||
|
pricelistRepo := repository.NewPricelistRepository(mariaDB)
|
||||||
|
|
||||||
// Get items from server
|
// Get items from server
|
||||||
serverItems, _, err := s.pricelistRepo.GetItems(localPL.ServerID, 0, 10000, "")
|
serverItems, _, err := pricelistRepo.GetItems(localPL.ServerID, 0, 10000, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("getting server pricelist items: %w", err)
|
return 0, fmt.Errorf("getting server pricelist items: %w", err)
|
||||||
}
|
}
|
||||||
@@ -312,8 +377,17 @@ func (s *Service) pushConfigurationCreate(change *localdb.PendingChange) error {
|
|||||||
return fmt.Errorf("unmarshaling configuration: %w", err)
|
return fmt.Errorf("unmarshaling configuration: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get database connection
|
||||||
|
mariaDB, err := s.connMgr.GetDB()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("database not available: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create repository
|
||||||
|
configRepo := repository.NewConfigurationRepository(mariaDB)
|
||||||
|
|
||||||
// Create on server
|
// Create on server
|
||||||
if err := s.configRepo.Create(&cfg); err != nil {
|
if err := configRepo.Create(&cfg); err != nil {
|
||||||
return fmt.Errorf("creating configuration on server: %w", err)
|
return fmt.Errorf("creating configuration on server: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -337,6 +411,15 @@ func (s *Service) pushConfigurationUpdate(change *localdb.PendingChange) error {
|
|||||||
return fmt.Errorf("unmarshaling configuration: %w", err)
|
return fmt.Errorf("unmarshaling configuration: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get database connection
|
||||||
|
mariaDB, err := s.connMgr.GetDB()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("database not available: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create repository
|
||||||
|
configRepo := repository.NewConfigurationRepository(mariaDB)
|
||||||
|
|
||||||
// Ensure we have a server ID before updating
|
// Ensure we have a server ID before updating
|
||||||
// If the payload doesn't have ID, get it from local configuration
|
// If the payload doesn't have ID, get it from local configuration
|
||||||
if cfg.ID == 0 {
|
if cfg.ID == 0 {
|
||||||
@@ -347,7 +430,7 @@ func (s *Service) pushConfigurationUpdate(change *localdb.PendingChange) error {
|
|||||||
|
|
||||||
if localCfg.ServerID == nil {
|
if localCfg.ServerID == nil {
|
||||||
// Configuration hasn't been synced yet, try to find it on server by UUID
|
// Configuration hasn't been synced yet, try to find it on server by UUID
|
||||||
serverCfg, err := s.configRepo.GetByUUID(cfg.UUID)
|
serverCfg, err := configRepo.GetByUUID(cfg.UUID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("configuration not yet synced to server: %w", err)
|
return fmt.Errorf("configuration not yet synced to server: %w", err)
|
||||||
}
|
}
|
||||||
@@ -363,7 +446,7 @@ func (s *Service) pushConfigurationUpdate(change *localdb.PendingChange) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Update on server
|
// Update on server
|
||||||
if err := s.configRepo.Update(&cfg); err != nil {
|
if err := configRepo.Update(&cfg); err != nil {
|
||||||
return fmt.Errorf("updating configuration on server: %w", err)
|
return fmt.Errorf("updating configuration on server: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -380,8 +463,17 @@ func (s *Service) pushConfigurationUpdate(change *localdb.PendingChange) error {
|
|||||||
|
|
||||||
// pushConfigurationDelete deletes a configuration from the server
|
// pushConfigurationDelete deletes a configuration from the server
|
||||||
func (s *Service) pushConfigurationDelete(change *localdb.PendingChange) error {
|
func (s *Service) pushConfigurationDelete(change *localdb.PendingChange) error {
|
||||||
|
// Get database connection
|
||||||
|
mariaDB, err := s.connMgr.GetDB()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("database not available: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create repository
|
||||||
|
configRepo := repository.NewConfigurationRepository(mariaDB)
|
||||||
|
|
||||||
// Get the configuration from server by UUID to get the ID
|
// Get the configuration from server by UUID to get the ID
|
||||||
cfg, err := s.configRepo.GetByUUID(change.EntityUUID)
|
cfg, err := configRepo.GetByUUID(change.EntityUUID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Already deleted or not found, consider it successful
|
// Already deleted or not found, consider it successful
|
||||||
slog.Warn("configuration not found on server, considering delete successful", "uuid", change.EntityUUID)
|
slog.Warn("configuration not found on server, considering delete successful", "uuid", change.EntityUUID)
|
||||||
@@ -389,7 +481,7 @@ func (s *Service) pushConfigurationDelete(change *localdb.PendingChange) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Delete from server
|
// Delete from server
|
||||||
if err := s.configRepo.Delete(cfg.ID); err != nil {
|
if err := configRepo.Delete(cfg.ID); err != nil {
|
||||||
return fmt.Errorf("deleting configuration from server: %w", err)
|
return fmt.Errorf("deleting configuration from server: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,23 +5,23 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"git.mchus.pro/mchus/quoteforge/internal/db"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Worker performs background synchronization at regular intervals
|
// Worker performs background synchronization at regular intervals
|
||||||
type Worker struct {
|
type Worker struct {
|
||||||
service *Service
|
service *Service
|
||||||
db *gorm.DB
|
connMgr *db.ConnectionManager
|
||||||
interval time.Duration
|
interval time.Duration
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
stopCh chan struct{}
|
stopCh chan struct{}
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewWorker creates a new background sync worker
|
// NewWorker creates a new background sync worker
|
||||||
func NewWorker(service *Service, db *gorm.DB, interval time.Duration) *Worker {
|
func NewWorker(service *Service, connMgr *db.ConnectionManager, interval time.Duration) *Worker {
|
||||||
return &Worker{
|
return &Worker{
|
||||||
service: service,
|
service: service,
|
||||||
db: db,
|
connMgr: connMgr,
|
||||||
interval: interval,
|
interval: interval,
|
||||||
logger: slog.Default(),
|
logger: slog.Default(),
|
||||||
stopCh: make(chan struct{}),
|
stopCh: make(chan struct{}),
|
||||||
@@ -30,11 +30,7 @@ func NewWorker(service *Service, db *gorm.DB, interval time.Duration) *Worker {
|
|||||||
|
|
||||||
// isOnline checks if the database connection is available
|
// isOnline checks if the database connection is available
|
||||||
func (w *Worker) isOnline() bool {
|
func (w *Worker) isOnline() bool {
|
||||||
sqlDB, err := w.db.DB()
|
return w.connMgr.IsOnline()
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return sqlDB.Ping() == nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start begins the background sync loop in a goroutine
|
// Start begins the background sync loop in a goroutine
|
||||||
|
|||||||
@@ -203,12 +203,6 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// Fallback showToast function for cases where base.html isn't loaded properly
|
|
||||||
const showToast = window.showToast || function(msg, type) {
|
|
||||||
// Simple fallback: just alert the message
|
|
||||||
alert(`${type ? type + ': ' : ''}${msg}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
let currentTab = 'alerts';
|
let currentTab = 'alerts';
|
||||||
let currentPage = 1;
|
let currentPage = 1;
|
||||||
let totalPages = 1;
|
let totalPages = 1;
|
||||||
@@ -597,8 +591,21 @@ async function fetchPreview() {
|
|||||||
document.getElementById('modal-new-price').textContent =
|
document.getElementById('modal-new-price').textContent =
|
||||||
data.new_price ? '$' + parseFloat(data.new_price).toFixed(2) : '—';
|
data.new_price ? '$' + parseFloat(data.new_price).toFixed(2) : '—';
|
||||||
|
|
||||||
// Update quote count
|
// Update quote count with new format "N (всего: M)"
|
||||||
document.getElementById('modal-quote-count').textContent = data.quote_count || 0;
|
let quoteCountText = '';
|
||||||
|
if (data.quote_count_period !== undefined && data.quote_count_total !== undefined) {
|
||||||
|
if (data.quote_count_period === data.quote_count_total) {
|
||||||
|
// If period count equals total count, just show the total
|
||||||
|
quoteCountText = data.quote_count_total;
|
||||||
|
} else {
|
||||||
|
// Show both counts in format "N (всего: M)"
|
||||||
|
quoteCountText = data.quote_count_period + ' (всего: ' + data.quote_count_total + ')';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Fallback for older API responses
|
||||||
|
quoteCountText = data.quote_count || 0;
|
||||||
|
}
|
||||||
|
document.getElementById('modal-quote-count').textContent = quoteCountText;
|
||||||
}
|
}
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
console.error('Preview fetch error:', e);
|
console.error('Preview fetch error:', e);
|
||||||
@@ -891,9 +898,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
const initialTab = urlParams.get('tab') || 'alerts';
|
const initialTab = urlParams.get('tab') || 'alerts';
|
||||||
loadTab(initialTab);
|
loadTab(initialTab);
|
||||||
|
|
||||||
// Check write permission for admin pricing link
|
|
||||||
checkWritePermission();
|
|
||||||
|
|
||||||
// Add event listeners for preview updates
|
// Add event listeners for preview updates
|
||||||
document.getElementById('modal-period').addEventListener('change', fetchPreview);
|
document.getElementById('modal-period').addEventListener('change', fetchPreview);
|
||||||
document.getElementById('modal-coefficient').addEventListener('input', debounceFetchPreview);
|
document.getElementById('modal-coefficient').addEventListener('input', debounceFetchPreview);
|
||||||
@@ -1028,7 +1032,23 @@ function closePricelistsCreateModal() {
|
|||||||
document.getElementById('pricelists-create-modal').classList.remove('flex');
|
document.getElementById('pricelists-create-modal').classList.remove('flex');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function checkOnlineStatus() {
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/db-status');
|
||||||
|
const data = await resp.json();
|
||||||
|
return data.connected === true;
|
||||||
|
} catch(e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function createPricelist() {
|
async function createPricelist() {
|
||||||
|
// Check if online before creating
|
||||||
|
const isOnline = await checkOnlineStatus();
|
||||||
|
if (!isOnline) {
|
||||||
|
throw new Error('Создание прайслистов доступно только в онлайн режиме');
|
||||||
|
}
|
||||||
|
|
||||||
const resp = await fetch('/api/pricelists', {
|
const resp = await fetch('/api/pricelists', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
@@ -1046,6 +1066,13 @@ async function createPricelist() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function deletePricelist(id) {
|
async function deletePricelist(id) {
|
||||||
|
// Check if online before deleting
|
||||||
|
const isOnline = await checkOnlineStatus();
|
||||||
|
if (!isOnline) {
|
||||||
|
showToast('Удаление прайслистов доступно только в онлайн режиме', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!confirm('Удалить этот прайслист?')) return;
|
if (!confirm('Удалить этот прайслист?')) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
<a href="/" class="text-xl font-bold text-blue-600">QuoteForge</a>
|
<a href="/" class="text-xl font-bold text-blue-600">QuoteForge</a>
|
||||||
<div class="hidden md:flex space-x-4">
|
<div class="hidden md:flex space-x-4">
|
||||||
<a href="/configurator" class="text-gray-600 hover:text-gray-900 px-3 py-2 text-sm">Конфигуратор</a>
|
<a href="/configurator" class="text-gray-600 hover:text-gray-900 px-3 py-2 text-sm">Конфигуратор</a>
|
||||||
<a id="admin-pricing-link" href="/admin/pricing" class="text-gray-600 hover:text-gray-900 px-3 py-2 text-sm hidden">Администратор цен</a>
|
<a id="admin-pricing-link" href="/admin/pricing" class="text-gray-600 hover:text-gray-900 px-3 py-2 text-sm">Администратор цен</a>
|
||||||
<a href="/setup" class="text-gray-600 hover:text-gray-900 px-3 py-2 text-sm">Настройки</a>
|
<a href="/setup" class="text-gray-600 hover:text-gray-900 px-3 py-2 text-sm">Настройки</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -44,6 +44,52 @@
|
|||||||
|
|
||||||
<div id="toast" class="fixed bottom-4 right-4 z-50"></div>
|
<div id="toast" class="fixed bottom-4 right-4 z-50"></div>
|
||||||
|
|
||||||
|
<!-- Sync Info Modal -->
|
||||||
|
<div id="sync-modal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 hidden">
|
||||||
|
<div class="bg-white rounded-lg shadow-xl max-w-md w-full mx-4">
|
||||||
|
<div class="p-6">
|
||||||
|
<div class="flex justify-between items-center mb-4">
|
||||||
|
<h3 class="text-lg font-medium text-gray-900">Информация о синхронизации</h3>
|
||||||
|
<button onclick="closeSyncModal()" class="text-gray-400 hover:text-gray-500">
|
||||||
|
<svg class="w-6 h-6" 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 class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h4 class="font-medium text-gray-900">Статус БД</h4>
|
||||||
|
<p id="modal-db-status" class="text-sm text-gray-600">Проверка...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 class="font-medium text-gray-900">Количество ошибок</h4>
|
||||||
|
<p id="modal-error-count" class="text-sm text-gray-600">0</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 class="font-medium text-gray-900">Последняя синхронизация</h4>
|
||||||
|
<p id="modal-last-sync" class="text-sm text-gray-600">-</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 class="font-medium text-gray-900">Список ошибок</h4>
|
||||||
|
<div id="modal-errors-list" class="mt-2 text-sm text-gray-600 max-h-40 overflow-y-auto">
|
||||||
|
<p>Нет ошибок</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 flex justify-end">
|
||||||
|
<button onclick="closeSyncModal()" class="px-4 py-2 bg-gray-600 text-white rounded-md hover:bg-gray-700">
|
||||||
|
Закрыть
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<footer class="fixed bottom-0 left-0 right-0 bg-gray-800 text-gray-300 text-xs py-1 px-4">
|
<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">
|
<div class="max-w-7xl mx-auto flex justify-between">
|
||||||
<span id="db-status">БД: проверка...</span>
|
<span id="db-status">БД: проверка...</span>
|
||||||
@@ -59,68 +105,78 @@
|
|||||||
setTimeout(() => el.innerHTML = '', 3000);
|
setTimeout(() => el.innerHTML = '', 3000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Open sync modal
|
||||||
|
function openSyncModal() {
|
||||||
|
const modal = document.getElementById('sync-modal');
|
||||||
|
if (modal) {
|
||||||
|
modal.classList.remove('hidden');
|
||||||
|
// Load sync info when modal opens
|
||||||
|
loadSyncInfo();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close sync modal
|
||||||
|
function closeSyncModal() {
|
||||||
|
const modal = document.getElementById('sync-modal');
|
||||||
|
if (modal) {
|
||||||
|
modal.classList.add('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load sync info for modal
|
||||||
|
async function loadSyncInfo() {
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/sync/info');
|
||||||
|
const data = await resp.json();
|
||||||
|
|
||||||
|
document.getElementById('modal-db-status').textContent = data.is_online ? 'Подключено' : 'Отключено';
|
||||||
|
document.getElementById('modal-error-count').textContent = data.error_count;
|
||||||
|
|
||||||
|
if (data.last_sync_at) {
|
||||||
|
const date = new Date(data.last_sync_at);
|
||||||
|
document.getElementById('modal-last-sync').textContent = date.toLocaleString('ru-RU');
|
||||||
|
} else {
|
||||||
|
document.getElementById('modal-last-sync').textContent = 'Нет данных';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load error list
|
||||||
|
const errorsList = document.getElementById('modal-errors-list');
|
||||||
|
if (data.errors && data.errors.length > 0) {
|
||||||
|
errorsList.innerHTML = data.errors.map(error =>
|
||||||
|
`<div class="mb-1"><span class="font-medium">${error.timestamp}</span>: ${error.message}</div>`
|
||||||
|
).join('');
|
||||||
|
} else {
|
||||||
|
errorsList.innerHTML = '<p>Нет ошибок</p>';
|
||||||
|
}
|
||||||
|
} catch(e) {
|
||||||
|
console.error('Failed to load sync info:', e);
|
||||||
|
document.getElementById('modal-db-status').textContent = 'Ошибка загрузки';
|
||||||
|
document.getElementById('modal-error-count').textContent = '0';
|
||||||
|
document.getElementById('modal-last-sync').textContent = '-';
|
||||||
|
document.getElementById('modal-errors-list').innerHTML = '<p>Ошибка загрузки данных</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Event delegation for sync dropdown and actions
|
// Event delegation for sync dropdown and actions
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
checkDbStatus();
|
checkDbStatus();
|
||||||
checkWritePermission();
|
checkWritePermission();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle keyboard navigation for dropdown
|
// Event delegation for sync actions
|
||||||
document.addEventListener('keydown', function(e) {
|
|
||||||
if (e.key === 'Escape') {
|
|
||||||
const dropdownMenu = document.getElementById('sync-dropdown-menu');
|
|
||||||
if (dropdownMenu) {
|
|
||||||
dropdownMenu.classList.add('hidden');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Event delegation for all sync actions
|
|
||||||
document.body.addEventListener('click', function(e) {
|
document.body.addEventListener('click', function(e) {
|
||||||
// Handle dropdown toggle
|
// Handle sync button click (full sync only)
|
||||||
const dropdownButton = e.target.closest('#sync-dropdown-button');
|
const syncButton = e.target.closest('#sync-button');
|
||||||
if (dropdownButton) {
|
if (syncButton) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const dropdownMenu = document.getElementById('sync-dropdown-menu');
|
const button = syncButton;
|
||||||
if (dropdownMenu) {
|
|
||||||
dropdownMenu.classList.toggle('hidden');
|
|
||||||
// Update aria-expanded
|
|
||||||
const isExpanded = dropdownMenu.classList.contains('hidden');
|
|
||||||
dropdownButton.setAttribute('aria-expanded', !isExpanded);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle sync actions
|
|
||||||
const actionButton = e.target.closest('[data-action]');
|
|
||||||
if (actionButton) {
|
|
||||||
const action = actionButton.dataset.action;
|
|
||||||
const button = actionButton; // Keep reference to original button
|
|
||||||
|
|
||||||
// Add loading state
|
// Add loading state
|
||||||
const originalHTML = button.innerHTML;
|
const originalHTML = button.innerHTML;
|
||||||
button.disabled = true;
|
button.disabled = true;
|
||||||
button.innerHTML = '<svg class="animate-spin w-4 h-4 inline mr-2" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg> Синхронизация...';
|
button.innerHTML = '<svg class="animate-spin w-4 h-4 inline mr-2" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg>';
|
||||||
|
|
||||||
if (action === 'push-changes') {
|
fullSync(button, originalHTML);
|
||||||
pushPendingChanges(button, originalHTML);
|
|
||||||
} else if (action === 'full-sync') {
|
|
||||||
fullSync(button, originalHTML);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Close dropdown when clicking outside
|
|
||||||
document.body.addEventListener('click', function(e) {
|
|
||||||
const dropdownButton = document.getElementById('sync-dropdown-button');
|
|
||||||
const dropdownMenu = document.getElementById('sync-dropdown-menu');
|
|
||||||
|
|
||||||
if (dropdownButton && dropdownMenu &&
|
|
||||||
!dropdownButton.contains(e.target) &&
|
|
||||||
!dropdownMenu.contains(e.target)) {
|
|
||||||
dropdownMenu.classList.add('hidden');
|
|
||||||
if (dropdownButton) {
|
|
||||||
dropdownButton.setAttribute('aria-expanded', 'false');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -132,8 +188,8 @@
|
|||||||
|
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
showToast(successMessage, 'success');
|
showToast(successMessage, 'success');
|
||||||
// Update last sync time
|
// Update last sync time - removed since dropdown is gone
|
||||||
loadLastSyncTime();
|
// loadLastSyncTime();
|
||||||
} else {
|
} else {
|
||||||
showToast('Ошибка: ' + (data.error || 'неизвестная ошибка'), 'error');
|
showToast('Ошибка: ' + (data.error || 'неизвестная ошибка'), 'error');
|
||||||
}
|
}
|
||||||
@@ -181,37 +237,36 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Admin pricing link is now always visible
|
||||||
|
// Write permission is checked at operation time (create/delete)
|
||||||
async function checkWritePermission() {
|
async function checkWritePermission() {
|
||||||
try {
|
// No longer needed - link always visible in offline-first mode
|
||||||
const resp = await fetch('/api/pricelists/can-write');
|
// Operations will check online status when executed
|
||||||
const data = await resp.json();
|
|
||||||
if (data.can_write) {
|
|
||||||
const link = document.getElementById('admin-pricing-link');
|
|
||||||
if (link) link.classList.remove('hidden');
|
|
||||||
}
|
|
||||||
} catch(e) {
|
|
||||||
console.error('Failed to check write permission:', e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load last sync time for dropdown
|
// Load last sync time for dropdown (removed since dropdown is gone)
|
||||||
async function loadLastSyncTime() {
|
// async function loadLastSyncTime() {
|
||||||
try {
|
// try {
|
||||||
const resp = await fetch('/api/sync/status');
|
// const resp = await fetch('/api/sync/status');
|
||||||
const data = await resp.json();
|
// const data = await resp.json();
|
||||||
if (data.last_pricelist_sync) {
|
// if (data.last_pricelist_sync) {
|
||||||
const date = new Date(data.last_pricelist_sync);
|
// const date = new Date(data.last_pricelist_sync);
|
||||||
document.getElementById('last-sync-time').textContent = date.toLocaleString('ru-RU');
|
// document.getElementById('last-sync-time').textContent = date.toLocaleString('ru-RU');
|
||||||
} else {
|
// } else {
|
||||||
document.getElementById('last-sync-time').textContent = 'Нет данных';
|
// document.getElementById('last-sync-time').textContent = 'Нет данных';
|
||||||
}
|
// }
|
||||||
} catch(e) {
|
// } catch(e) {
|
||||||
console.error('Failed to load last sync time:', e);
|
// console.error('Failed to load last sync time:', e);
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
|
|
||||||
// Load last sync time when page loads
|
// Call functions immediately to ensure they run even before DOMContentLoaded
|
||||||
document.addEventListener('DOMContentLoaded', loadLastSyncTime);
|
// This ensures username and admin link are visible ASAP
|
||||||
|
checkDbStatus();
|
||||||
|
checkWritePermission();
|
||||||
|
|
||||||
|
// Load last sync time - removed since dropdown is gone
|
||||||
|
// document.addEventListener('DOMContentLoaded', loadLastSyncTime);
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -421,18 +421,23 @@ async function loadLatestPricelistVersion() {
|
|||||||
const pricelist = await resp.json();
|
const pricelist = await resp.json();
|
||||||
document.getElementById('pricelist-version').textContent = pricelist.version;
|
document.getElementById('pricelist-version').textContent = pricelist.version;
|
||||||
document.getElementById('pricelist-badge').classList.remove('hidden');
|
document.getElementById('pricelist-badge').classList.remove('hidden');
|
||||||
|
} else if (resp.status === 404) {
|
||||||
|
// No active pricelist (normal in offline mode or when not synced)
|
||||||
|
document.getElementById('pricelist-version').textContent = 'Не загружен';
|
||||||
|
document.getElementById('pricelist-badge').classList.remove('hidden');
|
||||||
|
document.getElementById('pricelist-badge').classList.add('bg-gray-100', 'text-gray-600');
|
||||||
} else {
|
} else {
|
||||||
// Show error in badge
|
// Real error
|
||||||
document.getElementById('pricelist-version').textContent = 'Ошибка загрузки';
|
document.getElementById('pricelist-version').textContent = 'Ошибка загрузки';
|
||||||
document.getElementById('pricelist-badge').classList.remove('hidden');
|
document.getElementById('pricelist-badge').classList.remove('hidden');
|
||||||
document.getElementById('pricelist-badge').classList.add('bg-red-100', 'text-red-800');
|
document.getElementById('pricelist-badge').classList.add('bg-red-100', 'text-red-800');
|
||||||
}
|
}
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
// Show error in badge
|
// Network error or other exception
|
||||||
console.error('Failed to load pricelist version:', e);
|
console.error('Failed to load pricelist version:', e);
|
||||||
document.getElementById('pricelist-version').textContent = 'Ошибка загрузки';
|
document.getElementById('pricelist-version').textContent = 'Не доступен';
|
||||||
document.getElementById('pricelist-badge').classList.remove('hidden');
|
document.getElementById('pricelist-badge').classList.remove('hidden');
|
||||||
document.getElementById('pricelist-badge').classList.add('bg-red-100', 'text-red-800');
|
document.getElementById('pricelist-badge').classList.add('bg-gray-100', 'text-gray-600');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
{{define "sync_status"}}
|
{{define "sync_status"}}
|
||||||
<div class="flex items-center gap-2 relative">
|
<div class="flex items-center gap-2 relative">
|
||||||
{{if .IsOffline}}
|
{{if .IsOffline}}
|
||||||
<span class="flex items-center gap-1 text-red-600" title="Offline">
|
<span class="flex items-center gap-1 text-red-600 cursor-pointer" title="Offline" onclick="openSyncModal()">
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<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="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
|
||||||
</svg>
|
</svg>
|
||||||
</span>
|
</span>
|
||||||
{{else}}
|
{{else}}
|
||||||
<span class="flex items-center gap-1 text-green-600" title="Online">
|
<span class="flex items-center gap-1 text-green-600 cursor-pointer" title="Online" onclick="openSyncModal()">
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<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="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||||
</svg>
|
</svg>
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{if gt .PendingCount 0}}
|
{{if gt .PendingCount 0}}
|
||||||
<span class="bg-yellow-100 text-yellow-800 px-2 py-0.5 rounded-full text-xs font-medium flex items-center gap-1">
|
<span class="bg-yellow-100 text-yellow-800 px-2 py-0.5 rounded-full text-xs font-medium flex items-center gap-1 cursor-pointer" onclick="openSyncModal()">
|
||||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
|
||||||
</svg>
|
</svg>
|
||||||
@@ -23,40 +23,15 @@
|
|||||||
</span>
|
</span>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
<!-- Dropdown button for sync actions -->
|
<!-- Sync button (full sync only) -->
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<button id="sync-dropdown-button"
|
<button id="sync-button"
|
||||||
aria-label="Меню синхронизации"
|
aria-label="Синхронизация"
|
||||||
aria-haspopup="true"
|
|
||||||
aria-expanded="false"
|
|
||||||
class="text-gray-600 hover:text-gray-800 text-xs flex items-center gap-1">
|
class="text-gray-600 hover:text-gray-800 text-xs flex items-center gap-1">
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<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="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Dropdown menu -->
|
|
||||||
<div id="sync-dropdown-menu" class="absolute right-0 mt-2 w-48 bg-white rounded-md shadow-lg py-1 hidden z-50">
|
|
||||||
<button data-action="push-changes" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 w-full text-left">
|
|
||||||
<svg class="w-4 h-4 inline mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
|
|
||||||
</svg>
|
|
||||||
Push changes
|
|
||||||
</button>
|
|
||||||
<button data-action="full-sync" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 w-full text-left">
|
|
||||||
<svg class="w-4 h-4 inline mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
|
|
||||||
</svg>
|
|
||||||
Full sync
|
|
||||||
</button>
|
|
||||||
<div class="border-t border-gray-100 my-1"></div>
|
|
||||||
<div class="px-4 py-2 text-xs text-gray-500">
|
|
||||||
<svg class="w-4 h-4 inline mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
|
||||||
</svg>
|
|
||||||
Последняя синхронизация: <span id="last-sync-time">-</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -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
|
|
||||||
setTimeout(() => {
|
// Check if restart is required
|
||||||
showStatus('✓ Настройки сохранены. Проверка подключения...', 'success');
|
if (data.restart_required) {
|
||||||
// Poll until server is back
|
// In normal mode, restart must be done manually
|
||||||
checkServerReady();
|
setTimeout(() => {
|
||||||
}, 2000);
|
showStatus('⚠️ Пожалуйста, перезапустите приложение вручную для применения изменений', 'warning');
|
||||||
|
}, 2000);
|
||||||
|
} else {
|
||||||
|
// In setup mode, auto-restart is happening
|
||||||
|
setTimeout(() => {
|
||||||
|
showStatus('✓ Настройки сохранены. Проверка подключения...', 'success');
|
||||||
|
// Poll until server is back
|
||||||
|
checkServerReady();
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
showStatus(data.error, 'error');
|
showStatus(data.error, 'error');
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user