Compare commits
13 Commits
ec3c16f3fc
...
693c1d05d7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
693c1d05d7 | ||
|
|
7fb9dd0267 | ||
|
|
61646bea46 | ||
|
|
9495f929aa | ||
|
|
b80bde7dac | ||
|
|
e307a2765d | ||
|
|
6f1feb942a | ||
|
|
236e37376e | ||
|
|
ded6e09b5e | ||
|
|
96bbe0a510 | ||
|
|
b672cbf27d | ||
|
|
e206531364 | ||
|
|
9bd2acd4f7 |
49
CLAUDE.md
49
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 |
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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}}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}}
|
||||
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user