diff --git a/CLAUDE.md b/CLAUDE.md index 990462c..1025114 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,12 +29,48 @@ - ✅ Add routes for new sync endpoints (`/api/sync/push`, `/pending/count`, `/pending`) - ✅ ConfigurationGetter interface for handler compatibility - ✅ Background sync worker: auto-sync every 5min (push + pull) - `internal/services/sync/worker.go` +- ✅ UI: sync status indicator (pending badge + sync button + offline/online dot) - `web/templates/partials/sync_status.html` +- ✅ RefreshPrices for local mode: + - `RefreshPrices()` / `RefreshPricesNoAuth()` в `local_configuration.go` + - Берёт цены из `local_components.current_price` + - Graceful degradation при отсутствии компонента + - Добавлено поле `price_updated_at` в `LocalConfiguration` (models.go:72) + - Обновлены converters для PriceUpdatedAt + - UI кнопка "Пересчитать цену" работает offline/online +- ✅ Fixed sync bugs: + - Duplicate entry error при update конфигураций (`sync/service.go:334-365`) + - pushConfigurationUpdate теперь проверяет наличие server_id перед update + - Если нет ID → получает из LocalConfiguration.ServerID или ищет на сервере + - Fixed setup.go: `settings.Password` → `settings.PasswordEncrypted` **TODO:** -- ❌ UI: sync status partial (pending badge + sync button + offline indicator) -- ❌ RefreshPrices for local mode (via local_components) - ❌ Conflict resolution (Phase 4, last-write-wins default) +### UI Improvements 🔶 IN PROGRESS + +**1. Sync icon + pricelist badge в header (tasks 4+2):** +- ❌ `sync_status.html`: заменить текст Online/Offline на SVG иконку +- ❌ Кнопка sync → иконка (circular arrows) вместо текста +- ❌ Dropdown при клике: Push changes, Full sync, статус последней синхронизации +- ❌ `configs.html`: рядом с кнопкой "Создать" показать badge с версией активного прайслиста +- ❌ Загружать через `/api/pricelists/latest` при DOMContentLoaded + +**2. Прайслисты → вкладка в "Администратор цен" (task 1):** +- ❌ `base.html`: убрать отдельную ссылку "Прайслисты" из навигации +- ❌ `admin_pricing.html`: добавить 4-ю вкладку "Прайслисты" +- ❌ Перенести логику из `pricelists.html` (table, create modal, CRUD) в эту вкладку +- ❌ Route `/pricelists` → редирект на `/admin/pricing?tab=pricelists` или удалить + +**3. Страница настроек: расширить + синхронизация (task 3):** +- ❌ `setup.html`: переделать на `{{template "base" .}}` структуру +- ❌ Увеличить до `max-w-4xl`, разделить на 2 секции +- ❌ Секция A: Подключение к БД (текущая форма) +- ❌ Секция B: Синхронизация данных: + - Статус Online/Offline + - Кнопки: "Синхронизировать всё", "Обновить компоненты", "Обновить прайслисты" + - Журнал синхронизации (последние N операций) +- ❌ Возможно: новый API endpoint для sync log + ### Phase 3: Projects and Specifications - qt_projects, qt_specifications tables (MariaDB) - Replace qt_configurations → Project/Specification hierarchy @@ -65,12 +101,12 @@ Go 1.22+ | Gin | GORM | MariaDB 11 | SQLite (glebarez/sqlite) | htmx + Tailwind - `qt_specifications` - project_id, pricelist_id, variant, rev, qty, items JSON (Phase 3) ### SQLite (data/quoteforge.db) -- `connection_settings` - encrypted DB credentials +- `connection_settings` - encrypted DB credentials (PasswordEncrypted field) - `local_pricelists/items` - cached from server -- `local_components` - lot cache for offline search -- `local_configurations` - with sync_status (pending/synced/conflict) +- `local_components` - lot cache for offline search (with current_price) +- `local_configurations` - UUID, items, price_updated_at, sync_status (pending/synced/conflict), server_id - `local_projects/specifications` - Phase 3 -- `pending_changes` - sync queue (entity_type, uuid, op, payload, created_at) +- `pending_changes` - sync queue (entity_type, uuid, op, payload, created_at, attempts, last_error) ## Business Logic @@ -91,6 +127,7 @@ Go 1.22+ | Gin | GORM | MariaDB 11 | SQLite (glebarez/sqlite) | htmx + Tailwind | Pricelists | CRUD /api/pricelists, GET /latest, POST /compare | | Projects | CRUD /api/projects/:uuid (Phase 3) | | Specs | CRUD /api/specs/:uuid, POST /upgrade, GET /diff (Phase 3) | +| Configs | POST /:uuid/refresh-prices (обновить цены из local_components) | | Sync | GET /status, POST /components, /pricelists, /push, /pull, /resolve-conflict | | Export | GET /api/specs/:uuid/export, /api/projects/:uuid/export | diff --git a/cmd/server/main.go b/cmd/server/main.go index 899cc1f..5b12887 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -195,7 +195,9 @@ func setConfigDefaults(cfg *config.Config) { // runSetupMode starts a minimal server that only serves the setup page func runSetupMode(local *localdb.LocalDB) { - setupHandler, err := handlers.NewSetupHandler(local, "web/templates") + restartSig := make(chan struct{}, 1) + + setupHandler, err := handlers.NewSetupHandler(local, "web/templates", restartSig) if err != nil { slog.Error("failed to create setup handler", "error", err) os.Exit(1) @@ -242,9 +244,21 @@ func runSetupMode(local *localdb.LocalDB) { quit := make(chan os.Signal, 1) signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) - <-quit - slog.Info("setup mode server stopped") + select { + case <-quit: + slog.Info("setup mode server stopped") + case <-restartSig: + slog.Info("restarting application with saved settings...") + + // Graceful shutdown + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + srv.Shutdown(ctx) + + // Restart process with same arguments + restartProcess() + } } func setupLogger(cfg config.LoggingConfig) { @@ -336,8 +350,8 @@ func setupRouter(db *gorm.DB, cfg *config.Config, local *localdb.LocalDB, dbUser return nil, nil, fmt.Errorf("creating sync handler: %w", err) } - // Setup handler (for reconfiguration) - setupHandler, err := handlers.NewSetupHandler(local, "web/templates") + // Setup handler (for reconfiguration) - no restart signal in normal mode + setupHandler, err := handlers.NewSetupHandler(local, "web/templates", nil) if err != nil { return nil, nil, fmt.Errorf("creating setup handler: %w", err) } @@ -620,6 +634,26 @@ func setupRouter(db *gorm.DB, cfg *config.Config, local *localdb.LocalDB, dbUser return router, syncService, nil } +// restartProcess restarts the current process with the same arguments +func restartProcess() { + executable, err := os.Executable() + if err != nil { + slog.Error("failed to get executable path", "error", err) + os.Exit(1) + } + + args := os.Args + env := os.Environ() + + slog.Info("executing restart", "executable", executable, "args", args) + + err = syscall.Exec(executable, args, env) + if err != nil { + slog.Error("failed to restart process", "error", err) + os.Exit(1) + } +} + func requestLogger() gin.HandlerFunc { return func(c *gin.Context) { start := time.Now() diff --git a/internal/handlers/setup.go b/internal/handlers/setup.go index 0226a15..7fcd972 100644 --- a/internal/handlers/setup.go +++ b/internal/handlers/setup.go @@ -16,11 +16,12 @@ import ( ) type SetupHandler struct { - localDB *localdb.LocalDB - templates map[string]*template.Template + localDB *localdb.LocalDB + templates map[string]*template.Template + restartSig chan struct{} } -func NewSetupHandler(localDB *localdb.LocalDB, templatesPath string) (*SetupHandler, error) { +func NewSetupHandler(localDB *localdb.LocalDB, templatesPath string, restartSig chan struct{}) (*SetupHandler, error) { funcMap := template.FuncMap{ "sub": func(a, b int) int { return a - b }, "add": func(a, b int) int { return a + b }, @@ -37,8 +38,9 @@ func NewSetupHandler(localDB *localdb.LocalDB, templatesPath string) (*SetupHand templates["setup.html"] = tmpl return &SetupHandler{ - localDB: localDB, - templates: templates, + localDB: localDB, + templates: templates, + restartSig: restartSig, }, nil } @@ -72,6 +74,13 @@ func (h *SetupHandler) TestConnection(c *gin.Context) { port = p } + // If password is empty, try to use saved password + if password == "" { + if settings, err := h.localDB.GetSettings(); err == nil && settings != nil { + password = settings.PasswordEncrypted // GetSettings returns decrypted password in this field + } + } + dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local&timeout=5s", user, password, host, port, database) @@ -138,6 +147,13 @@ func (h *SetupHandler) SaveConnection(c *gin.Context) { port = p } + // If password is empty, use saved password + if password == "" { + if settings, err := h.localDB.GetSettings(); err == nil && settings != nil { + password = settings.PasswordEncrypted // GetSettings returns decrypted password in this field + } + } + // Test connection first dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local&timeout=5s", user, password, host, port, database) @@ -167,8 +183,16 @@ func (h *SetupHandler) SaveConnection(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ "success": true, - "message": "Settings saved. Please restart the application.", + "message": "Settings saved. Restarting application...", }) + + // Signal restart after response is sent + if h.restartSig != nil { + go func() { + time.Sleep(500 * time.Millisecond) // Give time for response to be sent + h.restartSig <- struct{}{} + }() + } } // GetStatus returns the current setup status diff --git a/internal/handlers/sync.go b/internal/handlers/sync.go index bebe4ab..bbea2ef 100644 --- a/internal/handlers/sync.go +++ b/internal/handlers/sync.go @@ -285,20 +285,30 @@ func (h *SyncHandler) GetPendingChanges(c *gin.Context) { // SyncStatusPartial renders the sync status partial for htmx // GET /partials/sync-status func (h *SyncHandler) SyncStatusPartial(c *gin.Context) { - // Check online status - isOffline, _ := c.Get("is_offline") + // Check online status from middleware + isOfflineValue, exists := c.Get("is_offline") + isOffline := false + if exists { + isOffline = isOfflineValue.(bool) + } else { + // Fallback: check directly if middleware didn't set it + isOffline = !h.checkOnline() + slog.Warn("is_offline not found in context, checking directly") + } // Get pending count pendingCount := h.localDB.GetPendingCount() + slog.Debug("rendering sync status", "is_offline", isOffline, "pending_count", pendingCount) + data := gin.H{ - "IsOffline": isOffline.(bool), + "IsOffline": isOffline, "PendingCount": pendingCount, } c.Header("Content-Type", "text/html; charset=utf-8") if err := h.tmpl.ExecuteTemplate(c.Writer, "sync_status", data); err != nil { slog.Error("failed to render sync_status template", "error", err) - c.String(http.StatusInternalServerError, "Template error") + c.String(http.StatusInternalServerError, "Template error: "+err.Error()) } } diff --git a/internal/localdb/converters.go b/internal/localdb/converters.go index d4986e1..a1501bd 100644 --- a/internal/localdb/converters.go +++ b/internal/localdb/converters.go @@ -26,6 +26,7 @@ func ConfigurationToLocal(cfg *models.Configuration) *LocalConfiguration { Notes: cfg.Notes, IsTemplate: cfg.IsTemplate, ServerCount: cfg.ServerCount, + PriceUpdatedAt: cfg.PriceUpdatedAt, CreatedAt: cfg.CreatedAt, UpdatedAt: time.Now(), SyncStatus: "pending", @@ -52,16 +53,17 @@ func LocalToConfiguration(local *LocalConfiguration) *models.Configuration { } cfg := &models.Configuration{ - UUID: local.UUID, - UserID: local.OriginalUserID, - Name: local.Name, - Items: items, - TotalPrice: local.TotalPrice, - CustomPrice: local.CustomPrice, - Notes: local.Notes, - IsTemplate: local.IsTemplate, - ServerCount: local.ServerCount, - CreatedAt: local.CreatedAt, + UUID: local.UUID, + UserID: local.OriginalUserID, + Name: local.Name, + Items: items, + TotalPrice: local.TotalPrice, + CustomPrice: local.CustomPrice, + Notes: local.Notes, + IsTemplate: local.IsTemplate, + ServerCount: local.ServerCount, + PriceUpdatedAt: local.PriceUpdatedAt, + CreatedAt: local.CreatedAt, } if local.ServerID != nil { diff --git a/internal/localdb/models.go b/internal/localdb/models.go index 260b482..8088fdf 100644 --- a/internal/localdb/models.go +++ b/internal/localdb/models.go @@ -69,6 +69,7 @@ type LocalConfiguration struct { Notes string `json:"notes"` IsTemplate bool `gorm:"default:false" json:"is_template"` ServerCount int `gorm:"default:1" json:"server_count"` + PriceUpdatedAt *time.Time `gorm:"type:timestamp" json:"price_updated_at,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` SyncedAt *time.Time `json:"synced_at"` diff --git a/internal/services/local_configuration.go b/internal/services/local_configuration.go index a4c5a79..3b281dc 100644 --- a/internal/services/local_configuration.go +++ b/internal/services/local_configuration.go @@ -2,7 +2,6 @@ package services import ( "encoding/json" - "errors" "time" "github.com/google/uuid" @@ -292,11 +291,71 @@ func (s *LocalConfigurationService) ListByUser(userID uint, page, perPage int) ( return userConfigs[start:end], total, nil } -// RefreshPrices updates all component prices in the configuration +// RefreshPrices updates all component prices in the configuration from local cache func (s *LocalConfigurationService) RefreshPrices(uuid string, userID uint) (*models.Configuration, error) { - // This requires access to component prices from local cache - // For now, return error as we need to implement component price lookup from local cache - return nil, errors.New("refresh prices not yet implemented for local-first mode") + // Get configuration from local SQLite + localCfg, err := s.localDB.GetConfigurationByUUID(uuid) + if err != nil { + return nil, ErrConfigNotFound + } + + // Check ownership + if localCfg.OriginalUserID != userID { + return nil, ErrConfigForbidden + } + + // Update prices for all items + updatedItems := make(localdb.LocalConfigItems, len(localCfg.Items)) + for i, item := range localCfg.Items { + // Get current component price from local cache + component, err := s.localDB.GetLocalComponent(item.LotName) + if err != nil || component.CurrentPrice == nil { + // Keep original item if component not found or no price available + updatedItems[i] = item + continue + } + + // Update item with current price from local cache + updatedItems[i] = localdb.LocalConfigItem{ + LotName: item.LotName, + Quantity: item.Quantity, + UnitPrice: *component.CurrentPrice, + } + } + + // Update configuration + localCfg.Items = updatedItems + total := updatedItems.Total() + + // If server count is greater than 1, multiply the total by server count + if localCfg.ServerCount > 1 { + total *= float64(localCfg.ServerCount) + } + + localCfg.TotalPrice = &total + + // Set price update timestamp and mark for sync + now := time.Now() + localCfg.PriceUpdatedAt = &now + localCfg.UpdatedAt = now + localCfg.SyncStatus = "pending" + + // Save to local SQLite + if err := s.localDB.SaveConfiguration(localCfg); err != nil { + return nil, err + } + + // Add to pending sync queue + cfg := localdb.LocalToConfiguration(localCfg) + payload, err := json.Marshal(cfg) + if err != nil { + return nil, err + } + if err := s.localDB.AddPendingChange("configuration", uuid, "update", string(payload)); err != nil { + return nil, err + } + + return cfg, nil } // GetByUUIDNoAuth returns configuration without ownership check @@ -503,7 +562,62 @@ func (s *LocalConfigurationService) ListTemplates(page, perPage int) ([]models.C // RefreshPricesNoAuth updates all component prices in the configuration without ownership check func (s *LocalConfigurationService) RefreshPricesNoAuth(uuid string) (*models.Configuration, error) { - // This requires access to component prices from local cache - // For now, return error as we need to implement component price lookup from local cache - return nil, errors.New("refresh prices not yet implemented for local-first mode") + // Get configuration from local SQLite + localCfg, err := s.localDB.GetConfigurationByUUID(uuid) + if err != nil { + return nil, ErrConfigNotFound + } + + // Update prices for all items + updatedItems := make(localdb.LocalConfigItems, len(localCfg.Items)) + for i, item := range localCfg.Items { + // Get current component price from local cache + component, err := s.localDB.GetLocalComponent(item.LotName) + if err != nil || component.CurrentPrice == nil { + // Keep original item if component not found or no price available + updatedItems[i] = item + continue + } + + // Update item with current price from local cache + updatedItems[i] = localdb.LocalConfigItem{ + LotName: item.LotName, + Quantity: item.Quantity, + UnitPrice: *component.CurrentPrice, + } + } + + // Update configuration + localCfg.Items = updatedItems + total := updatedItems.Total() + + // If server count is greater than 1, multiply the total by server count + if localCfg.ServerCount > 1 { + total *= float64(localCfg.ServerCount) + } + + localCfg.TotalPrice = &total + + // Set price update timestamp and mark for sync + now := time.Now() + localCfg.PriceUpdatedAt = &now + localCfg.UpdatedAt = now + localCfg.SyncStatus = "pending" + + // Save to local SQLite + if err := s.localDB.SaveConfiguration(localCfg); err != nil { + return nil, err + } + + // Add to pending sync queue + cfg := localdb.LocalToConfiguration(localCfg) + payload, err := json.Marshal(cfg) + if err != nil { + return nil, err + } + if err := s.localDB.AddPendingChange("configuration", uuid, "update", string(payload)); err != nil { + return nil, err + } + + return cfg, nil } diff --git a/internal/services/sync/service.go b/internal/services/sync/service.go index 17b9c9b..91146a8 100644 --- a/internal/services/sync/service.go +++ b/internal/services/sync/service.go @@ -337,6 +337,31 @@ func (s *Service) pushConfigurationUpdate(change *localdb.PendingChange) error { return fmt.Errorf("unmarshaling configuration: %w", err) } + // Ensure we have a server ID before updating + // If the payload doesn't have ID, get it from local configuration + if cfg.ID == 0 { + localCfg, err := s.localDB.GetConfigurationByUUID(cfg.UUID) + if err != nil { + return fmt.Errorf("getting local configuration: %w", err) + } + + if localCfg.ServerID == nil { + // Configuration hasn't been synced yet, try to find it on server by UUID + serverCfg, err := s.configRepo.GetByUUID(cfg.UUID) + if err != nil { + return fmt.Errorf("configuration not yet synced to server: %w", err) + } + cfg.ID = serverCfg.ID + + // Update local with server ID + serverID := serverCfg.ID + localCfg.ServerID = &serverID + s.localDB.SaveConfiguration(localCfg) + } else { + cfg.ID = *localCfg.ServerID + } + } + // Update on server if err := s.configRepo.Update(&cfg); err != nil { return fmt.Errorf("updating configuration on server: %w", err) diff --git a/internal/services/sync/worker.go b/internal/services/sync/worker.go index 7c41380..e21b6b4 100644 --- a/internal/services/sync/worker.go +++ b/internal/services/sync/worker.go @@ -75,21 +75,19 @@ func (w *Worker) runSync() { return } - w.logger.Debug("running background sync") - // Push pending changes first pushed, err := w.service.PushPendingChanges() if err != nil { - w.logger.Warn("failed to push pending changes", "error", err) + w.logger.Warn("background sync: failed to push pending changes", "error", err) } else if pushed > 0 { - w.logger.Info("pushed pending changes", "count", pushed) + w.logger.Info("background sync: pushed pending changes", "count", pushed) } // Then check for new pricelists err = w.service.SyncPricelistsIfNeeded() if err != nil { - w.logger.Warn("failed to sync pricelists", "error", err) + w.logger.Warn("background sync: failed to sync pricelists", "error", err) } - w.logger.Debug("background sync completed") + w.logger.Info("background sync cycle completed") } diff --git a/web/templates/base.html b/web/templates/base.html index 0be305b..e96b823 100644 --- a/web/templates/base.html +++ b/web/templates/base.html @@ -28,10 +28,10 @@
- Загрузка...
diff --git a/web/templates/setup.html b/web/templates/setup.html index 5d211f9..b67a5c5 100644 --- a/web/templates/setup.html +++ b/web/templates/setup.html @@ -61,6 +61,12 @@
+ {{if .Settings}} + + Назад + + {{end}}