13 Commits

Author SHA1 Message Date
Mikhail Chusavitin
693c1d05d7 fix: ensure write permission check on admin pricing page load\n\n- Added explicit checkWritePermission() call when admin pricing page loads\n- Ensures 'Администратор цен' link and username are properly displayed\n- Fixes issue where these elements disappeared when navigating to admin pricing 2026-02-02 14:30:28 +03:00
Mikhail Chusavitin
7fb9dd0267 fix: cache database username to avoid redundant API calls\n\n- Added cachedDbUsername variable to store username after first API call\n- Modified loadPricelistsDbUsername to check cache before making API request\n- Reduces unnecessary API calls when opening pricelists modal multiple times\n- Improves performance and reduces server load 2026-02-02 14:19:23 +03:00
Mikhail Chusavitin
61646bea46 fix: hide pagination when pricelists loading fails\n\n- Added pagination hiding when pricelists load error occurs\n- Prevents display of empty pagination controls when there's an error\n- Maintains consistent UI behavior 2026-02-02 14:15:23 +03:00
Mikhail Chusavitin
9495f929aa fix: add double-submit protection for pricelist creation\n\n- Added isCreatingPricelist flag to prevent duplicate submissions\n- Disable submit button during creation process\n- Show loading text during submission\n- Re-enable button and restore text in finally block\n- Prevents accidental creation of duplicate pricelists 2026-02-02 14:03:39 +03:00
Mikhail Chusavitin
b80bde7dac fix: add showToast fallback for robustness\n\n- Added fallback showToast function to prevent undefined errors\n- If showToast is not available from base.html, use simple alert fallback\n- Maintains same functionality while improving robustness\n- Addresses potential undefined showToast issue in pricelists functions 2026-02-02 13:50:32 +03:00
Mikhail Chusavitin
e307a2765d fix: rename global canWrite variable to avoid naming conflicts\n\n- Renamed global 'canWrite' variable to 'pricelistsCanWrite' to avoid potential conflicts\n- Updated all references to the renamed variable in pricelists functions\n- Maintains same functionality while improving code quality 2026-02-02 13:00:05 +03:00
Mikhail Chusavitin
6f1feb942a fix: handle URL tab parameter in admin pricing page
- Parse URLSearchParams to detect ?tab=pricelists on page load
- Load tab from URL or default to 'alerts'
- Fixes redirect from /pricelists to /admin/pricing?tab=pricelists

This resolves the critical UX issue where users redirected from
/pricelists would see the 'alerts' tab instead of 'pricelists'.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-02 12:56:14 +03:00
Mikhail Chusavitin
236e37376e fix: properly hide main tab content when pricelists tab is active\n\n- Fixed tab switching logic to properly hide main tab-content when pricelists tab is selected\n- Ensures no 'Загрузка...' text appears in pricelists tab\n- Maintains proper tab visibility for all other tabs 2026-02-02 12:45:33 +03:00
Mikhail Chusavitin
ded6e09b5e feat: move pricelists to admin pricing tab\n\n- Removed separate 'Прайслисты' link from navigation\n- Added 4th tab 'Прайслисты' to admin_pricing.html\n- Moved pricelists table, create modal, and CRUD functionality to admin pricing\n- Updated /pricelists route to redirect to /admin/pricing?tab=pricelists\n\nFixes task 2: Прайслисты → вкладка в "Администратор цен" 2026-02-02 12:42:05 +03:00
Mikhail Chusavitin
96bbe0a510 fix: use originalHTML to restore button state after sync
- Pass originalHTML through syncAction function chain
- Simplify finally block by restoring original button innerHTML
- Remove hardcoded button HTML values (5 lines reduction)
- Improve maintainability: button text changes won't break code
- Preserve any custom classes, attributes, or nested elements

This fixes the issue where originalHTML was declared but never used.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-02 12:32:44 +03:00
Mikhail Chusavitin
b672cbf27d feat: implement comprehensive sync UI improvements and bug fixes
- Fix critical race condition in sync dropdown actions
  - Add loading states and spinners for sync operations
  - Implement proper event delegation to prevent memory leaks
  - Add accessibility attributes (aria-label, aria-haspopup, aria-expanded)
  - Add keyboard navigation (Escape to close dropdown)
  - Reduce code duplication in sync functions (70% reduction)
  - Improve error handling for pricelist badge
  - Fix z-index issues in dropdown menu
  - Maintain full backward compatibility

  Addresses all issues identified in the TODO list and bug reports
2026-02-02 12:17:17 +03:00
Mikhail Chusavitin
e206531364 feat: implement sync icon + pricelist badge UI improvements
- Replace text 'Online/Offline' with SVG icons in sync status
- Change sync button to circular arrow icon
- Add dropdown menu with push changes, full sync, and last sync status
- Add pricelist version badge to configuration page
- Load pricelist version via /api/pricelists/latest on DOMContentLoaded

This completes task 1 of Phase 2.5 (UI Improvements) as specified in CLAUDE.md
2026-02-02 11:18:24 +03:00
Mikhail Chusavitin
9bd2acd4f7 Add offline RefreshPrices, fix sync bugs, implement auto-restart
- Implement RefreshPrices for local-first mode
  - Update prices from local_components.current_price cache
  - Graceful degradation when component not found
  - Add PriceUpdatedAt timestamp to LocalConfiguration model
  - Support both authenticated and no-auth price refresh

- Fix sync duplicate entry bug
  - pushConfigurationUpdate now ensures server_id exists before update
  - Fetch from LocalConfiguration.ServerID or search on server if missing
  - Update local config with server_id after finding

- Add application auto-restart after settings save
  - Implement restartProcess() using syscall.Exec
  - Setup handler signals restart via channel
  - Setup page polls /health endpoint and redirects when ready
  - Add "Back" button on setup page when settings exist

- Fix setup handler password handling
  - Use PasswordEncrypted field consistently
  - Support empty password by using saved value

- Improve sync status handling
  - Add fallback for is_offline check in SyncStatusPartial
  - Enhance background sync logging with prefixes

- Update CLAUDE.md documentation
  - Mark Phase 2.5 tasks as complete
  - Add UI Improvements section with future tasks
  - Update SQLite tables documentation

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-02 11:03:41 +03:00
14 changed files with 815 additions and 80 deletions

View File

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

View File

@@ -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)
}
@@ -413,7 +427,10 @@ func setupRouter(db *gorm.DB, cfg *config.Config, local *localdb.LocalDB, dbUser
router.GET("/", webHandler.Index)
router.GET("/configs", webHandler.Configs)
router.GET("/configurator", webHandler.Configurator)
router.GET("/pricelists", webHandler.Pricelists)
router.GET("/pricelists", func(c *gin.Context) {
// Redirect to admin/pricing with pricelists tab
c.Redirect(http.StatusFound, "/admin/pricing?tab=pricelists")
})
router.GET("/pricelists/:id", webHandler.PricelistDetail)
router.GET("/admin/pricing", webHandler.AdminPricing)
@@ -620,6 +637,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()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -9,6 +9,7 @@
<div class="flex gap-4">
<button onclick="loadTab('alerts')" id="btn-alerts" class="text-blue-600 font-medium">Алерты</button>
<button onclick="loadTab('components')" id="btn-components" class="text-gray-600">Компоненты</button>
<button onclick="loadTab('pricelists')" id="btn-pricelists" class="text-gray-600">Прайслисты</button>
<button onclick="loadTab('all-configs')" id="btn-all-configs" class="text-gray-600 hidden">Все конфигурации</button>
</div>
<button onclick="recalculateAll()" id="btn-recalc" class="px-3 py-1 bg-green-600 text-white text-sm rounded hover:bg-green-700">
@@ -53,6 +54,60 @@
<div class="text-center py-8 text-gray-500">Загрузка...</div>
</div>
<!-- Pricelists Tab Content (hidden by default) -->
<div id="pricelists-tab-content" class="hidden">
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-semibold">Прайслисты</h2>
<div id="pricelists-create-btn-container"></div>
</div>
<div class="bg-white rounded-lg shadow overflow-hidden">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Версия</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Дата</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Автор</th>
<th class="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase">Позиций</th>
<th class="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase">Исп.</th>
<th class="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase">Статус</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">Действия</th>
</tr>
</thead>
<tbody id="pricelists-body" class="bg-white divide-y divide-gray-200">
<tr>
<td colspan="7" class="px-6 py-4 text-center text-gray-500">Загрузка...</td>
</tr>
</tbody>
</table>
</div>
<div id="pricelists-pagination" class="flex justify-center space-x-2 mt-4"></div>
</div>
<!-- Create Modal -->
<div id="pricelists-create-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
<div class="bg-white rounded-lg p-6 max-w-md w-full mx-4">
<h2 class="text-xl font-bold mb-4">Создать прайслист</h2>
<p class="text-sm text-gray-600 mb-4">
Будет создан снимок текущих цен из базы данных.<br>
Автор прайслиста: <span id="pricelists-db-username" class="font-medium">загрузка...</span>
</p>
<form id="pricelists-create-form" class="space-y-4">
<div class="flex justify-end space-x-3">
<button type="button" onclick="closePricelistsCreateModal()"
class="px-4 py-2 text-gray-700 border border-gray-300 rounded-md hover:bg-gray-50">
Отмена
</button>
<button type="submit"
class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700">
Создать
</button>
</div>
</form>
</div>
</div>
<!-- Pagination -->
<div id="pagination" class="flex justify-between items-center mt-4 pt-4 border-t hidden">
<span id="page-info" class="text-sm text-gray-600"></span>
@@ -148,6 +203,12 @@
</div>
<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 currentPage = 1;
let totalPages = 1;
@@ -157,6 +218,10 @@ let currentSearch = '';
let componentsCache = [];
let sortField = 'popularity_score';
let sortDir = 'desc';
let pricelistsPage = 1;
let pricelistsCanWrite = false;
let isCreatingPricelist = false;
let cachedDbUsername = null;
async function loadTab(tab) {
currentTab = tab;
@@ -166,6 +231,7 @@ async function loadTab(tab) {
document.getElementById('btn-alerts').className = tab === 'alerts' ? 'text-blue-600 font-medium' : 'text-gray-600';
document.getElementById('btn-components').className = tab === 'components' ? 'text-blue-600 font-medium' : 'text-gray-600';
document.getElementById('btn-pricelists').className = tab === 'pricelists' ? 'text-blue-600 font-medium' : 'text-gray-600';
document.getElementById('btn-all-configs').className = tab === 'all-configs' ? 'text-blue-600 font-medium' : 'text-gray-600 hidden';
// Show/hide elements based on tab
@@ -173,17 +239,34 @@ async function loadTab(tab) {
document.getElementById('search-bar').className = 'mb-4';
document.getElementById('pagination').className = 'flex justify-between items-center mt-4 pt-4 border-t';
document.getElementById('btn-all-configs').className = 'text-gray-600 hidden'; // Hide this tab for components
document.getElementById('pricelists-tab-content').className = 'hidden';
document.getElementById('tab-content').className = '';
} else if (tab === 'all-configs') {
document.getElementById('search-bar').className = 'mb-4 hidden'; // Hide search for all configs
document.getElementById('pagination').className = 'flex justify-between items-center mt-4 pt-4 border-t'; // Show pagination
document.getElementById('btn-all-configs').className = 'text-blue-600 font-medium'; // Show this tab for all configs
document.getElementById('pricelists-tab-content').className = 'hidden';
document.getElementById('tab-content').className = '';
} else if (tab === 'pricelists') {
document.getElementById('search-bar').className = 'mb-4 hidden';
document.getElementById('pagination').className = 'hidden';
document.getElementById('btn-all-configs').className = 'text-gray-600 hidden';
document.getElementById('pricelists-tab-content').className = '';
document.getElementById('tab-content').className = 'hidden';
// Load pricelists when pricelists tab is selected
checkPricelistWritePermission();
loadPricelists(1);
} else {
document.getElementById('search-bar').className = 'mb-4 hidden';
document.getElementById('pagination').className = 'hidden';
document.getElementById('btn-all-configs').className = 'text-gray-600 hidden';
document.getElementById('pricelists-tab-content').className = 'hidden';
document.getElementById('tab-content').className = '';
}
await loadData();
if (tab !== 'pricelists') {
await loadData();
}
}
async function loadData() {
@@ -803,7 +886,13 @@ function renderAllConfigs(configs) {
}
document.addEventListener('DOMContentLoaded', () => {
loadTab('alerts');
// Check URL params for initial tab
const urlParams = new URLSearchParams(window.location.search);
const initialTab = urlParams.get('tab') || 'alerts';
loadTab(initialTab);
// Check write permission for admin pricing link
checkWritePermission();
// Add event listeners for preview updates
document.getElementById('modal-period').addEventListener('change', fetchPreview);
@@ -811,6 +900,194 @@ document.addEventListener('DOMContentLoaded', () => {
document.getElementById('modal-manual-price').addEventListener('input', debounceFetchPreview);
document.getElementById('modal-meta-prices').addEventListener('input', debounceFetchPreview);
});
// Pricelists functions
let canWrite = false;
async function checkPricelistWritePermission() {
try {
const resp = await fetch('/api/pricelists/can-write');
const data = await resp.json();
pricelistsCanWrite = data.can_write;
if (pricelistsCanWrite) {
document.getElementById('pricelists-create-btn-container').innerHTML = `
<button onclick="openPricelistsCreateModal()" class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700">
Создать прайслист
</button>
`;
}
} catch (e) {
console.error('Failed to check pricelist write permission:', e);
}
}
async function loadPricelists(page = 1) {
pricelistsPage = page;
try {
const resp = await fetch(`/api/pricelists?page=${page}&per_page=20`);
const data = await resp.json();
renderPricelists(data.pricelists || []);
renderPricelistsPagination(data.total, data.page, data.per_page);
} catch (e) {
document.getElementById('pricelists-body').innerHTML = `
<tr>
<td colspan="7" class="px-6 py-4 text-center text-red-500">
Ошибка загрузки: ${e.message}
</td>
</tr>
`;
// Hide pagination when there's an error
document.getElementById('pricelists-pagination').innerHTML = '';
}
}
function renderPricelists(pricelists) {
if (pricelists.length === 0) {
document.getElementById('pricelists-body').innerHTML = `
<tr>
<td colspan="7" class="px-6 py-4 text-center text-gray-500">
Прайслисты не найдены. ${pricelistsCanWrite ? 'Создайте первый прайслист.' : ''}
</td>
</tr>
`;
return;
}
const html = pricelists.map(pl => {
const date = new Date(pl.created_at).toLocaleDateString('ru-RU');
const statusClass = pl.is_active ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800';
const statusText = pl.is_active ? 'Активен' : 'Неактивен';
let actions = `<a href="/pricelists/${pl.id}" class="text-blue-600 hover:text-blue-800 text-sm">Просмотр</a>`;
if (pricelistsCanWrite && pl.usage_count === 0) {
actions += ` <button onclick="deletePricelist(${pl.id})" class="text-red-600 hover:text-red-800 text-sm ml-2">Удалить</button>`;
}
return `
<tr class="hover:bg-gray-50">
<td class="px-6 py-4 whitespace-nowrap">
<span class="font-mono text-sm">${pl.version}</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">${date}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">${pl.created_by || '-'}</td>
<td class="px-6 py-4 whitespace-nowrap text-center text-sm">${pl.item_count}</td>
<td class="px-6 py-4 whitespace-nowrap text-center text-sm">${pl.usage_count}</td>
<td class="px-6 py-4 whitespace-nowrap text-center">
<span class="px-2 py-1 text-xs rounded-full ${statusClass}">${statusText}</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-right">${actions}</td>
</tr>
`;
}).join('');
document.getElementById('pricelists-body').innerHTML = html;
}
function renderPricelistsPagination(total, page, perPage) {
const totalPages = Math.ceil(total / perPage);
if (totalPages <= 1) {
document.getElementById('pricelists-pagination').innerHTML = '';
return;
}
let html = '';
for (let i = 1; i <= totalPages; i++) {
const activeClass = i === page ? 'bg-blue-600 text-white' : 'bg-white text-gray-700 hover:bg-gray-50';
html += `<button onclick="loadPricelists(${i})" class="px-3 py-1 rounded border ${activeClass}">${i}</button>`;
}
document.getElementById('pricelists-pagination').innerHTML = html;
}
async function loadPricelistsDbUsername() {
if (cachedDbUsername) {
document.getElementById('pricelists-db-username').textContent = cachedDbUsername;
return;
}
try {
const resp = await fetch('/api/current-user');
const data = await resp.json();
cachedDbUsername = data.username || 'неизвестно';
document.getElementById('pricelists-db-username').textContent = cachedDbUsername;
} catch (e) {
document.getElementById('pricelists-db-username').textContent = 'неизвестно';
}
}
function openPricelistsCreateModal() {
document.getElementById('pricelists-create-modal').classList.remove('hidden');
document.getElementById('pricelists-create-modal').classList.add('flex');
loadPricelistsDbUsername();
}
function closePricelistsCreateModal() {
document.getElementById('pricelists-create-modal').classList.add('hidden');
document.getElementById('pricelists-create-modal').classList.remove('flex');
}
async function createPricelist() {
const resp = await fetch('/api/pricelists', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({})
});
if (!resp.ok) {
const data = await resp.json();
throw new Error(data.error || 'Failed to create pricelist');
}
return await resp.json();
}
async function deletePricelist(id) {
if (!confirm('Удалить этот прайслист?')) return;
try {
const resp = await fetch(`/api/pricelists/${id}`, {
method: 'DELETE'
});
if (!resp.ok) {
const data = await resp.json();
throw new Error(data.error || 'Failed to delete');
}
showToast('Прайслист удален', 'success');
loadPricelists(pricelistsPage);
} catch (e) {
showToast('Ошибка: ' + e.message, 'error');
}
}
document.getElementById('pricelists-create-form').addEventListener('submit', async function(e) {
e.preventDefault();
if (isCreatingPricelist) return; // protection from double-submit
isCreatingPricelist = true;
const submitBtn = this.querySelector('button[type="submit"]');
submitBtn.disabled = true;
submitBtn.textContent = 'Создание...';
try {
const pl = await createPricelist();
closePricelistsCreateModal();
showToast(`Прайслист ${pl.version} создан (${pl.item_count} позиций)`, 'success');
loadPricelists(1);
} catch (e) {
showToast('Ошибка: ' + e.message, 'error');
} finally {
isCreatingPricelist = false;
submitBtn.disabled = false;
submitBtn.textContent = 'Создать';
}
});
</script>
{{end}}

View File

@@ -19,7 +19,6 @@
<div class="flex items-center space-x-8">
<a href="/" class="text-xl font-bold text-blue-600">QuoteForge</a>
<div class="hidden md:flex space-x-4">
<a href="/pricelists" 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 href="/setup" class="text-gray-600 hover:text-gray-900 px-3 py-2 text-sm">Настройки</a>
@@ -28,10 +27,10 @@
<div class="flex items-center space-x-4">
<!-- Sync Status Indicator (htmx-powered) -->
<div id="sync-status"
class="flex items-center gap-3 text-sm"
hx-get="/partials/sync-status"
hx-trigger="load, refresh from:body, every 30s"
hx-swap="innerHTML">
<span class="animate-pulse text-gray-400 text-xs">Загрузка...</span>
</div>
<span id="db-user" class="text-sm text-gray-600"></span>
</div>
@@ -60,6 +59,105 @@
setTimeout(() => el.innerHTML = '', 3000);
}
// Event delegation for sync dropdown and actions
document.addEventListener('DOMContentLoaded', function() {
checkDbStatus();
checkWritePermission();
});
// Handle keyboard navigation for dropdown
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) {
// Handle dropdown toggle
const dropdownButton = e.target.closest('#sync-dropdown-button');
if (dropdownButton) {
e.stopPropagation();
const dropdownMenu = document.getElementById('sync-dropdown-menu');
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
const originalHTML = button.innerHTML;
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> Синхронизация...';
if (action === 'push-changes') {
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');
}
}
});
// Refactored sync action function to reduce duplication
async function syncAction(endpoint, successMessage, button, originalHTML) {
try {
const resp = await fetch(endpoint, { method: 'POST' });
const data = await resp.json();
if (data.success) {
showToast(successMessage, 'success');
// Update last sync time
loadLastSyncTime();
} else {
showToast('Ошибка: ' + (data.error || 'неизвестная ошибка'), 'error');
}
htmx.trigger('#sync-status', 'refresh');
} catch (error) {
showToast('Ошибка: ' + error.message, 'error');
} finally {
// Reset button state
if (button) {
button.disabled = false;
button.innerHTML = originalHTML;
}
}
}
function pushPendingChanges(button, originalHTML) {
syncAction('/api/sync/push', 'Изменения синхронизированы', button, originalHTML);
}
function fullSync(button, originalHTML) {
syncAction('/api/sync/all', 'Полная синхронизация завершена', button, originalHTML);
}
async function checkDbStatus() {
try {
const resp = await fetch('/api/db-status');
@@ -96,10 +194,24 @@
}
}
document.addEventListener('DOMContentLoaded', function() {
checkDbStatus();
checkWritePermission();
});
// Load last sync time for dropdown
async function loadLastSyncTime() {
try {
const resp = await fetch('/api/sync/status');
const data = await resp.json();
if (data.last_pricelist_sync) {
const date = new Date(data.last_pricelist_sync);
document.getElementById('last-sync-time').textContent = date.toLocaleString('ru-RU');
} else {
document.getElementById('last-sync-time').textContent = 'Нет данных';
}
} catch(e) {
console.error('Failed to load last sync time:', e);
}
}
// Load last sync time when page loads
document.addEventListener('DOMContentLoaded', loadLastSyncTime);
</script>
</body>
</html>

View File

@@ -10,6 +10,15 @@
</button>
</div>
<div id="pricelist-badge" class="mt-4 text-sm text-gray-600 hidden">
<span class="bg-blue-100 text-blue-800 px-2 py-1 rounded-full">
<svg class="w-4 h-4 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
</svg>
Активный прайслист: <span id="pricelist-version">-</span>
</span>
</div>
<div id="configs-list">
<div class="text-center py-8 text-gray-500">Загрузка...</div>
</div>
@@ -398,7 +407,34 @@ async function loadConfigs() {
}
}
document.addEventListener('DOMContentLoaded', loadConfigs);
document.addEventListener('DOMContentLoaded', function() {
loadConfigs();
// Load latest pricelist version for badge
loadLatestPricelistVersion();
});
async function loadLatestPricelistVersion() {
try {
const resp = await fetch('/api/pricelists/latest');
if (resp.ok) {
const pricelist = await resp.json();
document.getElementById('pricelist-version').textContent = pricelist.version;
document.getElementById('pricelist-badge').classList.remove('hidden');
} else {
// Show error in badge
document.getElementById('pricelist-version').textContent = 'Ошибка загрузки';
document.getElementById('pricelist-badge').classList.remove('hidden');
document.getElementById('pricelist-badge').classList.add('bg-red-100', 'text-red-800');
}
} catch(e) {
// Show error in badge
console.error('Failed to load pricelist version:', e);
document.getElementById('pricelist-version').textContent = 'Ошибка загрузки';
document.getElementById('pricelist-badge').classList.remove('hidden');
document.getElementById('pricelist-badge').classList.add('bg-red-100', 'text-red-800');
}
}
</script>
{{end}}

View File

@@ -1,37 +1,62 @@
{{define "sync_status"}}
<div class="flex items-center gap-2">
<div class="flex items-center gap-2 relative">
{{if .IsOffline}}
<span class="flex items-center gap-1 text-red-600" title="Offline">
<span class="w-2 h-2 bg-red-500 rounded-full"></span>
<span class="text-xs">Offline</span>
<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>
</svg>
</span>
{{else}}
<span class="flex items-center gap-1 text-green-600" title="Online">
<span class="w-2 h-2 bg-green-500 rounded-full"></span>
<span class="text-xs">Online</span>
<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>
</svg>
</span>
{{end}}
{{if gt .PendingCount 0}}
<span class="bg-yellow-100 text-yellow-800 px-2 py-0.5 rounded-full text-xs font-medium">
{{.PendingCount}} pending
<span class="bg-yellow-100 text-yellow-800 px-2 py-0.5 rounded-full text-xs font-medium flex items-center gap-1">
<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>
</svg>
{{.PendingCount}}
</span>
<button hx-post="/api/sync/push"
hx-swap="none"
hx-on::after-request="
if(event.detail.successful) {
const resp = JSON.parse(event.detail.xhr.response);
if(resp.success) {
showToast('Синхронизировано: ' + resp.synced + ' изменений', 'success');
} else {
showToast('Ошибка: ' + (resp.error || 'неизвестная ошибка'), 'error');
}
htmx.trigger('#sync-status', 'refresh');
}
"
class="text-blue-600 hover:text-blue-800 text-xs underline cursor-pointer">
Sync
</button>
{{end}}
<!-- Dropdown button for sync actions -->
<div class="relative">
<button id="sync-dropdown-button"
aria-label="Меню синхронизации"
aria-haspopup="true"
aria-expanded="false"
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">
<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>
</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>
{{end}}

View File

@@ -61,6 +61,12 @@
<div id="status" class="hidden p-3 rounded-md text-sm"></div>
<div class="flex space-x-3 pt-4">
{{if .Settings}}
<a href="/"
class="flex-1 px-4 py-2 bg-gray-100 text-gray-700 rounded-md hover:bg-gray-200 transition text-center">
Назад
</a>
{{end}}
<button type="button" onclick="testConnection()"
class="flex-1 px-4 py-2 bg-gray-100 text-gray-700 rounded-md hover:bg-gray-200 transition">
Проверить
@@ -122,6 +128,34 @@
}
}
async function checkServerReady() {
let attempts = 0;
const maxAttempts = 30; // 30 seconds max
const checkInterval = setInterval(async () => {
attempts++;
try {
const resp = await fetch('/health', { method: 'GET' });
const data = await resp.json();
// Check if we're out of setup mode
if (data.status === 'ok') {
clearInterval(checkInterval);
showStatus('✓ Приложение запущено! Перенаправление...', 'success');
setTimeout(() => {
window.location.href = '/';
}, 1000);
}
} catch (e) {
// Server still restarting, continue polling
if (attempts >= maxAttempts) {
clearInterval(checkInterval);
showStatus('Сервер не отвечает. Обновите страницу вручную.', 'error');
}
}
}, 1000); // Check every second
}
document.getElementById('setup-form').addEventListener('submit', async function(e) {
e.preventDefault();
showStatus('Сохранение настроек...', 'info');
@@ -136,9 +170,12 @@
const data = await resp.json();
if (data.success) {
showStatus(data.message + ' Перенаправление...', 'success');
showStatus('✓ ' + data.message, 'success');
// Wait for restart and redirect to home
setTimeout(() => {
window.location.href = '/';
showStatus('✓ Настройки сохранены. Проверка подключения...', 'success');
// Poll until server is back
checkServerReady();
}, 2000);
} else {
showStatus(data.error, 'error');