Add projects table controls and sync status tab with app version

This commit is contained in:
Mikhail Chusavitin
2026-02-06 14:02:21 +03:00
parent 6ab1e9899e
commit b1b50ce2ef
8 changed files with 532 additions and 34 deletions

View File

@@ -7,12 +7,14 @@ import (
"fmt" "fmt"
"io/fs" "io/fs"
"log/slog" "log/slog"
"math"
"net/http" "net/http"
"os" "os"
"os/exec" "os/exec"
"os/signal" "os/signal"
"path/filepath" "path/filepath"
"runtime" "runtime"
"sort"
"strconv" "strconv"
"strings" "strings"
"syscall" "syscall"
@@ -42,6 +44,8 @@ import (
// Version is set via ldflags during build // Version is set via ldflags during build
var Version = "dev" var Version = "dev"
const backgroundSyncInterval = 5 * time.Minute
func main() { func main() {
configPath := flag.String("config", "", "path to config file (default: user state dir or QFS_CONFIG_PATH)") configPath := flag.String("config", "", "path to config file (default: user state dir or QFS_CONFIG_PATH)")
localDBPath := flag.String("localdb", "", "path to local SQLite database (default: user state dir or QFS_DB_PATH)") localDBPath := flag.String("localdb", "", "path to local SQLite database (default: user state dir or QFS_DB_PATH)")
@@ -207,7 +211,7 @@ func main() {
workerCtx, workerCancel := context.WithCancel(context.Background()) workerCtx, workerCancel := context.WithCancel(context.Background())
defer workerCancel() defer workerCancel()
syncWorker := sync.NewWorker(syncService, connMgr, 5*time.Minute) syncWorker := sync.NewWorker(syncService, connMgr, backgroundSyncInterval)
go syncWorker.Start(workerCtx) go syncWorker.Start(workerCtx)
srv := &http.Server{ srv := &http.Server{
@@ -580,7 +584,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
exportHandler := handlers.NewExportHandler(exportService, configService, componentService) exportHandler := handlers.NewExportHandler(exportService, configService, componentService)
pricingHandler := handlers.NewPricingHandler(mariaDB, pricingService, alertService, componentRepo, priceRepo, statsRepo) pricingHandler := handlers.NewPricingHandler(mariaDB, pricingService, alertService, componentRepo, priceRepo, statsRepo)
pricelistHandler := handlers.NewPricelistHandler(pricelistService, local) pricelistHandler := handlers.NewPricelistHandler(pricelistService, local)
syncHandler, err := handlers.NewSyncHandler(local, syncService, connMgr, templatesPath) syncHandler, err := handlers.NewSyncHandler(local, syncService, connMgr, templatesPath, backgroundSyncInterval)
if err != nil { if err != nil {
return nil, nil, fmt.Errorf("creating sync handler: %w", err) return nil, nil, fmt.Errorf("creating sync handler: %w", err)
} }
@@ -1041,10 +1045,32 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
status := c.DefaultQuery("status", "active") status := c.DefaultQuery("status", "active")
search := strings.ToLower(strings.TrimSpace(c.Query("search"))) search := strings.ToLower(strings.TrimSpace(c.Query("search")))
author := strings.ToLower(strings.TrimSpace(c.Query("author")))
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "10"))
sortField := strings.ToLower(strings.TrimSpace(c.DefaultQuery("sort", "created_at")))
sortDir := strings.ToLower(strings.TrimSpace(c.DefaultQuery("dir", "desc")))
if status != "active" && status != "archived" && status != "all" { if status != "active" && status != "archived" && status != "all" {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid status"}) c.JSON(http.StatusBadRequest, gin.H{"error": "invalid status"})
return return
} }
if page < 1 {
page = 1
}
if perPage < 1 {
perPage = 10
}
if perPage > 100 {
perPage = 100
}
if sortField != "name" && sortField != "created_at" {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid sort field"})
return
}
if sortDir != "asc" && sortDir != "desc" {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid sort direction"})
return
}
allProjects, err := projectService.ListByUser(dbUsername, true) allProjects, err := projectService.ListByUser(dbUsername, true)
if err != nil { if err != nil {
@@ -1064,12 +1090,69 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
if search != "" && !strings.Contains(strings.ToLower(p.Name), search) { if search != "" && !strings.Contains(strings.ToLower(p.Name), search) {
continue continue
} }
if author != "" && !strings.Contains(strings.ToLower(strings.TrimSpace(p.OwnerUsername)), author) {
continue
}
filtered = append(filtered, p) filtered = append(filtered, p)
} }
projectRows := make([]gin.H, 0, len(filtered)) sort.Slice(filtered, func(i, j int) bool {
for i := range filtered { left := filtered[i]
p := filtered[i] right := filtered[j]
if sortField == "name" {
leftName := strings.ToLower(strings.TrimSpace(left.Name))
rightName := strings.ToLower(strings.TrimSpace(right.Name))
if leftName == rightName {
if sortDir == "asc" {
return left.CreatedAt.Before(right.CreatedAt)
}
return left.CreatedAt.After(right.CreatedAt)
}
if sortDir == "asc" {
return leftName < rightName
}
return leftName > rightName
}
if left.CreatedAt.Equal(right.CreatedAt) {
leftName := strings.ToLower(strings.TrimSpace(left.Name))
rightName := strings.ToLower(strings.TrimSpace(right.Name))
if sortDir == "asc" {
return leftName < rightName
}
return leftName > rightName
}
if sortDir == "asc" {
return left.CreatedAt.Before(right.CreatedAt)
}
return left.CreatedAt.After(right.CreatedAt)
})
total := len(filtered)
totalPages := 0
if total > 0 {
totalPages = int(math.Ceil(float64(total) / float64(perPage)))
}
if totalPages > 0 && page > totalPages {
page = totalPages
}
start := (page - 1) * perPage
if start < 0 {
start = 0
}
end := start + perPage
if end > total {
end = total
}
paged := []models.Project{}
if start < total {
paged = filtered[start:end]
}
projectRows := make([]gin.H, 0, len(paged))
for i := range paged {
p := paged[i]
configs, err := projectService.ListConfigurations(p.UUID, dbUsername, "active") configs, err := projectService.ListConfigurations(p.UUID, dbUsername, "active")
if err != nil { if err != nil {
configs = &services.ProjectConfigurationsResult{ configs = &services.ProjectConfigurationsResult{
@@ -1093,10 +1176,16 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
} }
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"projects": projectRows, "projects": projectRows,
"status": status, "status": status,
"search": search, "search": search,
"total": len(projectRows), "author": author,
"sort": sortField,
"dir": sortDir,
"page": page,
"per_page": perPage,
"total": total,
"total_pages": totalPages,
}) })
}) })
@@ -1271,6 +1360,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
{ {
syncAPI.GET("/status", syncHandler.GetStatus) syncAPI.GET("/status", syncHandler.GetStatus)
syncAPI.GET("/info", syncHandler.GetInfo) syncAPI.GET("/info", syncHandler.GetInfo)
syncAPI.GET("/users-status", syncHandler.GetUsersStatus)
syncAPI.POST("/components", syncHandler.SyncComponents) syncAPI.POST("/components", syncHandler.SyncComponents)
syncAPI.POST("/pricelists", syncHandler.SyncPricelists) syncAPI.POST("/pricelists", syncHandler.SyncPricelists)
syncAPI.POST("/all", syncHandler.SyncAll) syncAPI.POST("/all", syncHandler.SyncAll)

View File

@@ -17,14 +17,16 @@ import (
// SyncHandler handles sync API endpoints // SyncHandler handles sync API endpoints
type SyncHandler struct { type SyncHandler struct {
localDB *localdb.LocalDB localDB *localdb.LocalDB
syncService *sync.Service syncService *sync.Service
connMgr *db.ConnectionManager connMgr *db.ConnectionManager
tmpl *template.Template autoSyncInterval time.Duration
onlineGraceFactor float64
tmpl *template.Template
} }
// NewSyncHandler creates a new sync handler // NewSyncHandler creates a new sync handler
func NewSyncHandler(localDB *localdb.LocalDB, syncService *sync.Service, connMgr *db.ConnectionManager, templatesPath string) (*SyncHandler, error) { func NewSyncHandler(localDB *localdb.LocalDB, syncService *sync.Service, connMgr *db.ConnectionManager, templatesPath string, autoSyncInterval time.Duration) (*SyncHandler, error) {
// Load sync_status partial template // Load sync_status partial template
partialPath := filepath.Join(templatesPath, "partials", "sync_status.html") partialPath := filepath.Join(templatesPath, "partials", "sync_status.html")
var tmpl *template.Template var tmpl *template.Template
@@ -39,10 +41,12 @@ func NewSyncHandler(localDB *localdb.LocalDB, syncService *sync.Service, connMgr
} }
return &SyncHandler{ return &SyncHandler{
localDB: localDB, localDB: localDB,
syncService: syncService, syncService: syncService,
connMgr: connMgr, connMgr: connMgr,
tmpl: tmpl, autoSyncInterval: autoSyncInterval,
onlineGraceFactor: 1.10,
tmpl: tmpl,
}, nil }, nil
} }
@@ -173,6 +177,7 @@ func (h *SyncHandler) SyncPricelists(c *gin.Context) {
Synced: synced, Synced: synced,
Duration: time.Since(startTime).String(), Duration: time.Since(startTime).String(),
}) })
h.syncService.RecordSyncHeartbeat()
} }
// SyncAllResponse represents result of full sync // SyncAllResponse represents result of full sync
@@ -238,6 +243,7 @@ func (h *SyncHandler) SyncAll(c *gin.Context) {
PricelistsSynced: pricelistsSynced, PricelistsSynced: pricelistsSynced,
Duration: time.Since(startTime).String(), Duration: time.Since(startTime).String(),
}) })
h.syncService.RecordSyncHeartbeat()
} }
// checkOnline checks if MariaDB is accessible // checkOnline checks if MariaDB is accessible
@@ -273,6 +279,7 @@ func (h *SyncHandler) PushPendingChanges(c *gin.Context) {
Synced: pushed, Synced: pushed,
Duration: time.Since(startTime).String(), Duration: time.Since(startTime).String(),
}) })
h.syncService.RecordSyncHeartbeat()
} }
// GetPendingCount returns the number of pending changes // GetPendingCount returns the number of pending changes
@@ -308,6 +315,14 @@ type SyncInfoResponse struct {
Errors []SyncError `json:"errors,omitempty"` Errors []SyncError `json:"errors,omitempty"`
} }
type SyncUsersStatusResponse struct {
IsOnline bool `json:"is_online"`
AutoSyncIntervalSeconds int64 `json:"auto_sync_interval_seconds"`
OnlineThresholdSeconds int64 `json:"online_threshold_seconds"`
GeneratedAt time.Time `json:"generated_at"`
Users []sync.UserSyncStatus `json:"users"`
}
// SyncError represents a sync error // SyncError represents a sync error
type SyncError struct { type SyncError struct {
Timestamp time.Time `json:"timestamp"` Timestamp time.Time `json:"timestamp"`
@@ -364,6 +379,40 @@ func (h *SyncHandler) GetInfo(c *gin.Context) {
}) })
} }
// GetUsersStatus returns last sync timestamps for users with sync heartbeats.
// GET /api/sync/users-status
func (h *SyncHandler) GetUsersStatus(c *gin.Context) {
threshold := time.Duration(float64(h.autoSyncInterval) * h.onlineGraceFactor)
isOnline := h.checkOnline()
if !isOnline {
c.JSON(http.StatusOK, SyncUsersStatusResponse{
IsOnline: false,
AutoSyncIntervalSeconds: int64(h.autoSyncInterval.Seconds()),
OnlineThresholdSeconds: int64(threshold.Seconds()),
GeneratedAt: time.Now().UTC(),
Users: []sync.UserSyncStatus{},
})
return
}
users, err := h.syncService.ListUserSyncStatuses(threshold)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": err.Error(),
})
return
}
c.JSON(http.StatusOK, SyncUsersStatusResponse{
IsOnline: true,
AutoSyncIntervalSeconds: int64(h.autoSyncInterval.Seconds()),
OnlineThresholdSeconds: int64(threshold.Seconds()),
GeneratedAt: time.Now().UTC(),
Users: users,
})
}
// SyncStatusPartial renders the sync status partial for htmx // SyncStatusPartial renders the sync status partial for htmx
// GET /partials/sync-status // GET /partials/sync-status
func (h *SyncHandler) SyncStatusPartial(c *gin.Context) { func (h *SyncHandler) SyncStatusPartial(c *gin.Context) {

View File

@@ -5,6 +5,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"log/slog" "log/slog"
"strings"
"time" "time"
"git.mchus.pro/mchus/quoteforge/internal/appmeta" "git.mchus.pro/mchus/quoteforge/internal/appmeta"
@@ -49,6 +50,13 @@ type SyncStatus struct {
NeedsSync bool `json:"needs_sync"` NeedsSync bool `json:"needs_sync"`
} }
type UserSyncStatus struct {
Username string `json:"username"`
LastSyncAt time.Time `json:"last_sync_at"`
AppVersion string `json:"app_version,omitempty"`
IsOnline bool `json:"is_online"`
}
// ConfigImportResult represents server->local configuration import stats. // ConfigImportResult represents server->local configuration import stats.
type ConfigImportResult struct { type ConfigImportResult struct {
Imported int `json:"imported"` Imported int `json:"imported"`
@@ -301,11 +309,104 @@ func (s *Service) SyncPricelists() (int, error) {
// Update last sync time // Update last sync time
s.localDB.SetLastSyncTime(time.Now()) s.localDB.SetLastSyncTime(time.Now())
s.RecordSyncHeartbeat()
slog.Info("pricelist sync completed", "synced", synced, "total", len(serverPricelists)) slog.Info("pricelist sync completed", "synced", synced, "total", len(serverPricelists))
return synced, nil return synced, nil
} }
// RecordSyncHeartbeat updates shared sync heartbeat for current DB user.
// Only users with write rights are expected to be able to update this table.
func (s *Service) RecordSyncHeartbeat() {
username := strings.TrimSpace(s.localDB.GetDBUser())
if username == "" {
return
}
mariaDB, err := s.getDB()
if err != nil || mariaDB == nil {
return
}
if err := ensureUserSyncStatusTable(mariaDB); err != nil {
slog.Warn("sync heartbeat: failed to ensure table", "error", err)
return
}
now := time.Now().UTC()
if err := mariaDB.Exec(`
INSERT INTO qt_pricelist_sync_status (username, last_sync_at, updated_at, app_version)
VALUES (?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
last_sync_at = VALUES(last_sync_at),
updated_at = VALUES(updated_at),
app_version = VALUES(app_version)
`, username, now, now, appmeta.Version()).Error; err != nil {
slog.Debug("sync heartbeat: skipped", "username", username, "error", err)
}
}
// ListUserSyncStatuses returns users who have recorded sync heartbeat.
func (s *Service) ListUserSyncStatuses(onlineThreshold time.Duration) ([]UserSyncStatus, error) {
mariaDB, err := s.getDB()
if err != nil || mariaDB == nil {
return nil, ErrOffline
}
if err := ensureUserSyncStatusTable(mariaDB); err != nil {
return nil, fmt.Errorf("ensure sync status table: %w", err)
}
type row struct {
Username string `gorm:"column:username"`
LastSyncAt time.Time `gorm:"column:last_sync_at"`
AppVersion string `gorm:"column:app_version"`
}
var rows []row
if err := mariaDB.Raw(`
SELECT username, last_sync_at, COALESCE(app_version, '') AS app_version
FROM qt_pricelist_sync_status
ORDER BY last_sync_at DESC, username ASC
`).Scan(&rows).Error; err != nil {
return nil, fmt.Errorf("load sync status rows: %w", err)
}
now := time.Now().UTC()
result := make([]UserSyncStatus, 0, len(rows))
for i := range rows {
r := rows[i]
result = append(result, UserSyncStatus{
Username: r.Username,
LastSyncAt: r.LastSyncAt,
AppVersion: strings.TrimSpace(r.AppVersion),
IsOnline: now.Sub(r.LastSyncAt) <= onlineThreshold,
})
}
return result, nil
}
func ensureUserSyncStatusTable(db *gorm.DB) error {
if err := db.Exec(`
CREATE TABLE IF NOT EXISTS qt_pricelist_sync_status (
username VARCHAR(100) NOT NULL,
last_sync_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
app_version VARCHAR(64) NULL,
PRIMARY KEY (username),
INDEX idx_qt_pricelist_sync_status_last_sync (last_sync_at)
)
`).Error; err != nil {
return err
}
// Backward compatibility for environments where table was created without app_version.
return db.Exec(`
ALTER TABLE qt_pricelist_sync_status
ADD COLUMN IF NOT EXISTS app_version VARCHAR(64) NULL
`).Error
}
// SyncPricelistItems synchronizes items for a specific pricelist // SyncPricelistItems synchronizes items for a specific pricelist
func (s *Service) SyncPricelistItems(localPricelistID uint) (int, error) { func (s *Service) SyncPricelistItems(localPricelistID uint) (int, error) {
// Get local pricelist // Get local pricelist
@@ -711,10 +812,10 @@ func (s *Service) pushConfigurationUpdate(change *localdb.PendingChange) error {
cfg.ID = serverCfg.ID cfg.ID = serverCfg.ID
} }
// Update local with server ID // Update local with server ID
serverID := cfg.ID serverID := cfg.ID
localCfg.ServerID = &serverID localCfg.ServerID = &serverID
s.localDB.SaveConfiguration(localCfg) s.localDB.SaveConfiguration(localCfg)
} else { } else {
cfg.ID = *localCfg.ServerID cfg.ID = *localCfg.ServerID
} }

View File

@@ -83,7 +83,11 @@ func (w *Worker) runSync() {
err = w.service.SyncPricelistsIfNeeded() err = w.service.SyncPricelistsIfNeeded()
if err != nil { if err != nil {
w.logger.Warn("background sync: failed to sync pricelists", "error", err) w.logger.Warn("background sync: failed to sync pricelists", "error", err)
return
} }
// Mark user's sync heartbeat (used for online/offline status in UI).
w.service.RecordSyncHeartbeat()
w.logger.Info("background sync cycle completed") w.logger.Info("background sync cycle completed")
} }

View File

@@ -0,0 +1,8 @@
CREATE TABLE IF NOT EXISTS qt_pricelist_sync_status (
username VARCHAR(100) NOT NULL,
last_sync_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
app_version VARCHAR(64) NULL,
PRIMARY KEY (username),
INDEX idx_qt_pricelist_sync_status_last_sync (last_sync_at)
);

View File

@@ -0,0 +1,2 @@
ALTER TABLE qt_pricelist_sync_status
ADD COLUMN IF NOT EXISTS app_version VARCHAR(64) NULL;

View File

@@ -10,6 +10,7 @@
<button onclick="loadTab('alerts')" id="btn-alerts" class="text-blue-600 font-medium">Алерты</button> <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('components')" id="btn-components" class="text-gray-600">Компоненты</button>
<button onclick="loadTab('pricelists')" id="btn-pricelists" class="text-gray-600">Прайслисты</button> <button onclick="loadTab('pricelists')" id="btn-pricelists" class="text-gray-600">Прайслисты</button>
<button onclick="loadTab('sync-status')" id="btn-sync-status" class="text-gray-600 hidden">Статус синхронизации</button>
<button onclick="loadTab('all-configs')" id="btn-all-configs" class="text-gray-600 hidden">Все конфигурации</button> <button onclick="loadTab('all-configs')" id="btn-all-configs" class="text-gray-600 hidden">Все конфигурации</button>
</div> </div>
<button onclick="recalculateAll()" id="btn-recalc" class="px-3 py-1 bg-green-600 text-white text-sm rounded hover:bg-green-700"> <button onclick="recalculateAll()" id="btn-recalc" class="px-3 py-1 bg-green-600 text-white text-sm rounded hover:bg-green-700">
@@ -85,6 +86,30 @@
<div id="pricelists-pagination" class="flex justify-center space-x-2 mt-4"></div> <div id="pricelists-pagination" class="flex justify-center space-x-2 mt-4"></div>
</div> </div>
<!-- Sync Status Tab Content (hidden by default) -->
<div id="sync-status-tab-content" class="hidden">
<div class="mb-4">
<h2 class="text-xl font-semibold">Статус синхронизации</h2>
</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>
</tr>
</thead>
<tbody id="sync-users-status-body" class="bg-white divide-y divide-gray-200">
<tr>
<td colspan="3" class="px-6 py-4 text-sm text-gray-500">Загрузка...</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Create Modal --> <!-- Create Modal -->
<div id="pricelists-create-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50"> <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"> <div class="bg-white rounded-lg p-6 max-w-md w-full mx-4">
@@ -226,16 +251,21 @@ let pricelistsPage = 1;
let pricelistsCanWrite = false; let pricelistsCanWrite = false;
let isCreatingPricelist = false; let isCreatingPricelist = false;
let cachedDbUsername = null; let cachedDbUsername = null;
let syncUsersStatusTimer = null;
async function loadTab(tab) { async function loadTab(tab) {
currentTab = tab; currentTab = tab;
currentPage = 1; currentPage = 1;
currentSearch = ''; currentSearch = '';
document.getElementById('search-input').value = ''; document.getElementById('search-input').value = '';
if (tab !== 'sync-status') {
stopSyncUsersStatusRefresh();
}
document.getElementById('btn-alerts').className = tab === 'alerts' ? 'text-blue-600 font-medium' : 'text-gray-600'; 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-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-pricelists').className = tab === 'pricelists' ? 'text-blue-600 font-medium' : 'text-gray-600';
document.getElementById('btn-sync-status').className = (tab === 'sync-status' ? 'text-blue-600 font-medium' : 'text-gray-600') + (pricelistsCanWrite ? '' : ' hidden');
document.getElementById('btn-all-configs').className = tab === 'all-configs' ? 'text-blue-600 font-medium' : 'text-gray-600 hidden'; document.getElementById('btn-all-configs').className = tab === 'all-configs' ? 'text-blue-600 font-medium' : 'text-gray-600 hidden';
// Show/hide elements based on tab // Show/hide elements based on tab
@@ -244,35 +274,69 @@ async function loadTab(tab) {
document.getElementById('pagination').className = 'flex justify-between items-center mt-4 pt-4 border-t'; 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('btn-all-configs').className = 'text-gray-600 hidden'; // Hide this tab for components
document.getElementById('pricelists-tab-content').className = 'hidden'; document.getElementById('pricelists-tab-content').className = 'hidden';
document.getElementById('sync-status-tab-content').className = 'hidden';
document.getElementById('tab-content').className = ''; document.getElementById('tab-content').className = '';
} else if (tab === 'all-configs') { } else if (tab === 'all-configs') {
document.getElementById('search-bar').className = 'mb-4 hidden'; // Hide search for 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('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('btn-all-configs').className = 'text-blue-600 font-medium'; // Show this tab for all configs
document.getElementById('pricelists-tab-content').className = 'hidden'; document.getElementById('pricelists-tab-content').className = 'hidden';
document.getElementById('sync-status-tab-content').className = 'hidden';
document.getElementById('tab-content').className = ''; document.getElementById('tab-content').className = '';
} else if (tab === 'pricelists') { } else if (tab === 'pricelists') {
document.getElementById('search-bar').className = 'mb-4 hidden'; document.getElementById('search-bar').className = 'mb-4 hidden';
document.getElementById('pagination').className = 'hidden'; document.getElementById('pagination').className = 'hidden';
document.getElementById('btn-all-configs').className = 'text-gray-600 hidden'; document.getElementById('btn-all-configs').className = 'text-gray-600 hidden';
document.getElementById('pricelists-tab-content').className = ''; document.getElementById('pricelists-tab-content').className = '';
document.getElementById('sync-status-tab-content').className = 'hidden';
document.getElementById('tab-content').className = 'hidden'; document.getElementById('tab-content').className = 'hidden';
// Load pricelists when pricelists tab is selected // Load pricelists when pricelists tab is selected
checkPricelistWritePermission(); checkPricelistWritePermission();
loadPricelists(1); loadPricelists(1);
} else if (tab === 'sync-status') {
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('sync-status-tab-content').className = '';
document.getElementById('tab-content').className = 'hidden';
await checkPricelistWritePermission();
if (!pricelistsCanWrite) {
await loadTab('alerts');
return;
}
await loadUsersSyncStatus();
startSyncUsersStatusRefresh();
} else { } else {
document.getElementById('search-bar').className = 'mb-4 hidden'; document.getElementById('search-bar').className = 'mb-4 hidden';
document.getElementById('pagination').className = 'hidden'; document.getElementById('pagination').className = 'hidden';
document.getElementById('btn-all-configs').className = 'text-gray-600 hidden'; document.getElementById('btn-all-configs').className = 'text-gray-600 hidden';
document.getElementById('pricelists-tab-content').className = 'hidden'; document.getElementById('pricelists-tab-content').className = 'hidden';
document.getElementById('sync-status-tab-content').className = 'hidden';
document.getElementById('tab-content').className = ''; document.getElementById('tab-content').className = '';
} }
if (tab !== 'pricelists') { if (tab !== 'pricelists' && tab !== 'sync-status') {
await loadData(); await loadData();
} }
} }
function stopSyncUsersStatusRefresh() {
if (syncUsersStatusTimer) {
clearInterval(syncUsersStatusTimer);
syncUsersStatusTimer = null;
}
}
function startSyncUsersStatusRefresh() {
stopSyncUsersStatusRefresh();
syncUsersStatusTimer = setInterval(() => {
if (currentTab === 'sync-status' && pricelistsCanWrite) {
loadUsersSyncStatus();
}
}, 30000);
}
async function loadData() { async function loadData() {
document.getElementById('tab-content').innerHTML = '<div class="text-center py-8 text-gray-500">Загрузка...</div>'; document.getElementById('tab-content').innerHTML = '<div class="text-center py-8 text-gray-500">Загрузка...</div>';
@@ -902,11 +966,12 @@ function renderAllConfigs(configs) {
document.getElementById('tab-content').innerHTML = html; document.getElementById('tab-content').innerHTML = html;
} }
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', async () => {
await checkPricelistWritePermission();
// Check URL params for initial tab // Check URL params for initial tab
const urlParams = new URLSearchParams(window.location.search); const urlParams = new URLSearchParams(window.location.search);
const initialTab = urlParams.get('tab') || 'alerts'; const initialTab = urlParams.get('tab') || 'alerts';
loadTab(initialTab); await loadTab(initialTab);
// Add event listeners for preview updates // Add event listeners for preview updates
document.getElementById('modal-period').addEventListener('change', fetchPreview); document.getElementById('modal-period').addEventListener('change', fetchPreview);
@@ -930,9 +995,89 @@ async function checkPricelistWritePermission() {
Создать прайслист Создать прайслист
</button> </button>
`; `;
document.getElementById('btn-sync-status').classList.remove('hidden');
if (currentTab === 'sync-status') {
await loadUsersSyncStatus();
startSyncUsersStatusRefresh();
}
} else {
document.getElementById('pricelists-create-btn-container').innerHTML = '';
document.getElementById('btn-sync-status').classList.add('hidden');
stopSyncUsersStatusRefresh();
if (currentTab === 'sync-status') {
await loadTab('alerts');
}
} }
} catch (e) { } catch (e) {
console.error('Failed to check pricelist write permission:', e); console.error('Failed to check pricelist write permission:', e);
document.getElementById('btn-sync-status').classList.add('hidden');
stopSyncUsersStatusRefresh();
}
}
function formatRelativeTime(lastSyncAt) {
const timestamp = new Date(lastSyncAt);
if (Number.isNaN(timestamp.getTime())) return '—';
const diffMinutes = Math.max(1, Math.floor((Date.now() - timestamp.getTime()) / 60000));
if (diffMinutes < 60) return `${diffMinutes} мин назад`;
const diffHours = Math.floor(diffMinutes / 60);
if (diffHours < 24) return `${diffHours} ч назад`;
const diffDays = Math.floor(diffHours / 24);
if (diffDays < 7) return `${diffDays} дн назад`;
const diffWeeks = Math.floor(diffDays / 7);
if (diffWeeks < 5) return `${diffWeeks} нед назад`;
const diffMonths = Math.floor(diffDays / 30);
if (diffMonths < 12) return `${diffMonths} мес назад`;
const diffYears = Math.floor(diffDays / 365);
return `${diffYears} г назад`;
}
async function loadUsersSyncStatus() {
if (!pricelistsCanWrite) return;
const body = document.getElementById('sync-users-status-body');
if (!body) return;
try {
const resp = await fetch('/api/sync/users-status');
const data = await resp.json();
if (!resp.ok) {
throw new Error(data.error || 'Ошибка загрузки');
}
const users = data.users || [];
if (users.length === 0) {
body.innerHTML = `
<tr>
<td colspan="3" class="px-6 py-4 text-sm text-gray-500">
Нет данных о синхронизации пользователей
</td>
</tr>
`;
return;
}
body.innerHTML = users.map(u => {
const statusClass = u.is_online ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-700';
const statusText = u.is_online ? 'онлайн' : formatRelativeTime(u.last_sync_at);
return `
<tr>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-800">${escapeHtml(u.username || '—')}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-600">${escapeHtml(u.app_version || '—')}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm">
<span class="px-2 py-1 text-xs rounded-full ${statusClass}">${statusText}</span>
</td>
</tr>
`;
}).join('');
} catch (e) {
body.innerHTML = `
<tr>
<td colspan="3" class="px-6 py-4 text-sm text-red-600">
Ошибка загрузки статусов синхронизации: ${escapeHtml(e.message || String(e))}
</td>
</tr>
`;
} }
} }

View File

@@ -30,6 +30,11 @@
<script> <script>
let status = 'active'; let status = 'active';
let projectsSearch = ''; let projectsSearch = '';
let authorSearch = '';
let currentPage = 1;
let perPage = 10;
let sortField = 'created_at';
let sortDir = 'desc';
function escapeHtml(text) { function escapeHtml(text) {
const div = document.createElement('div'); const div = document.createElement('div');
@@ -41,8 +46,33 @@ function formatMoney(v) {
return '$' + (v || 0).toLocaleString('en-US', {minimumFractionDigits: 2}); return '$' + (v || 0).toLocaleString('en-US', {minimumFractionDigits: 2});
} }
function formatDateTime(value) {
if (!value) return '—';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '—';
return date.toLocaleString('ru-RU', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
}
function toggleSort(field) {
if (sortField === field) {
sortDir = sortDir === 'asc' ? 'desc' : 'asc';
} else {
sortField = field;
sortDir = field === 'name' ? 'asc' : 'desc';
}
currentPage = 1;
loadProjects();
}
function setStatus(value) { function setStatus(value) {
status = value; status = value;
currentPage = 1;
document.getElementById('status-active-btn').className = value === 'active' document.getElementById('status-active-btn').className = value === 'active'
? 'px-4 py-2 text-sm font-medium bg-blue-600 text-white' ? 'px-4 py-2 text-sm font-medium bg-blue-600 text-white'
: 'px-4 py-2 text-sm font-medium bg-white text-gray-700 hover:bg-gray-50'; : 'px-4 py-2 text-sm font-medium bg-white text-gray-700 hover:bg-gray-50';
@@ -57,36 +87,73 @@ async function loadProjects() {
root.innerHTML = '<div class="text-gray-500">Загрузка...</div>'; root.innerHTML = '<div class="text-gray-500">Загрузка...</div>';
let rows = []; let rows = [];
let total = 0;
let totalPages = 0;
let page = currentPage;
try { try {
const resp = await fetch('/api/projects?status=' + status + '&search=' + encodeURIComponent(projectsSearch)); const params = new URLSearchParams({
status: status,
search: projectsSearch,
author: authorSearch,
page: String(currentPage),
per_page: String(perPage),
sort: sortField,
dir: sortDir
});
const resp = await fetch('/api/projects?' + params.toString());
if (!resp.ok) { if (!resp.ok) {
throw new Error('HTTP ' + resp.status); throw new Error('HTTP ' + resp.status);
} }
const data = await resp.json(); const data = await resp.json();
rows = data.projects || []; rows = data.projects || [];
total = data.total || 0;
totalPages = data.total_pages || 0;
page = data.page || currentPage;
currentPage = page;
} catch (e) { } catch (e) {
root.innerHTML = '<div class="text-red-600">Ошибка загрузки проектов: ' + escapeHtml(String(e.message || e)) + '</div>'; root.innerHTML = '<div class="text-red-600">Ошибка загрузки проектов: ' + escapeHtml(String(e.message || e)) + '</div>';
return; return;
} }
if (!rows.length) {
root.innerHTML = '<div class="text-gray-500">Проектов нет</div>';
return;
}
let html = '<div class="overflow-x-auto"><table class="w-full">'; let html = '<div class="overflow-x-auto"><table class="w-full">';
html += '<thead class="bg-gray-50"><tr>'; html += '<thead class="bg-gray-50">';
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Название проекта</th>'; html += '<tr>';
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">';
html += '<button type="button" onclick="toggleSort(\'name\')" class="inline-flex items-center gap-1 hover:text-gray-700">Название проекта';
if (sortField === 'name') {
html += sortDir === 'asc' ? ' <span>↑</span>' : ' <span>↓</span>';
}
html += '</button></th>';
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Автор</th>'; html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Автор</th>';
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">';
html += '<button type="button" onclick="toggleSort(\'created_at\')" class="inline-flex items-center gap-1 hover:text-gray-700">Создан';
if (sortField === 'created_at') {
html += sortDir === 'asc' ? ' <span>↑</span>' : ' <span>↓</span>';
}
html += '</button></th>';
html += '<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Кол-во квот</th>'; html += '<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Кол-во квот</th>';
html += '<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Сумма</th>'; html += '<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Сумма</th>';
html += '<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Действия</th>'; html += '<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Действия</th>';
html += '</tr></thead><tbody class="divide-y">'; html += '</tr>';
html += '<tr>';
html += '<th class="px-4 py-2"></th>';
html += '<th class="px-4 py-2"><input id="projects-author-filter" type="text" value="' + escapeHtml(authorSearch) + '" placeholder="Фильтр автора" class="w-full px-2 py-1 border rounded text-xs focus:ring-1 focus:ring-blue-500 focus:border-blue-500"></th>';
html += '<th class="px-4 py-2"></th>';
html += '<th class="px-4 py-2"></th>';
html += '<th class="px-4 py-2"></th>';
html += '<th class="px-4 py-2"></th>';
html += '</tr>';
html += '</thead><tbody class="divide-y">';
if (!rows.length) {
html += '<tr><td colspan="6" class="px-4 py-6 text-sm text-gray-500 text-center">Проектов нет</td></tr>';
}
rows.forEach(p => { rows.forEach(p => {
html += '<tr class="hover:bg-gray-50">'; html += '<tr class="hover:bg-gray-50">';
html += '<td class="px-4 py-3 text-sm font-medium"><a class="text-blue-600 hover:underline" href="/projects/' + p.uuid + '">' + escapeHtml(p.name) + '</a></td>'; html += '<td class="px-4 py-3 text-sm font-medium"><a class="text-blue-600 hover:underline" href="/projects/' + p.uuid + '">' + escapeHtml(p.name) + '</a></td>';
html += '<td class="px-4 py-3 text-sm text-gray-600">' + escapeHtml(p.owner_username || '—') + '</td>'; html += '<td class="px-4 py-3 text-sm text-gray-600">' + escapeHtml(p.owner_username || '—') + '</td>';
html += '<td class="px-4 py-3 text-sm text-gray-600">' + escapeHtml(formatDateTime(p.created_at)) + '</td>';
html += '<td class="px-4 py-3 text-sm text-right text-gray-700">' + (p.config_count || 0) + '</td>'; html += '<td class="px-4 py-3 text-sm text-right text-gray-700">' + (p.config_count || 0) + '</td>';
html += '<td class="px-4 py-3 text-sm text-right text-gray-700">' + formatMoney(p.total) + '</td>'; html += '<td class="px-4 py-3 text-sm text-right text-gray-700">' + formatMoney(p.total) + '</td>';
html += '<td class="px-4 py-3 text-sm text-right"><div class="inline-flex items-center gap-2">'; html += '<td class="px-4 py-3 text-sm text-right"><div class="inline-flex items-center gap-2">';
@@ -117,7 +184,38 @@ async function loadProjects() {
}); });
html += '</tbody></table></div>'; html += '</tbody></table></div>';
if (totalPages > 1) {
html += '<div class="flex items-center justify-between mt-4 pt-4 border-t">';
html += '<div class="text-sm text-gray-600">Показано ' + rows.length + ' из ' + total + '</div>';
html += '<div class="inline-flex items-center gap-1">';
html += '<button type="button" onclick="goToPage(' + (page - 1) + ')" ' + (page <= 1 ? 'disabled' : '') + ' class="px-3 py-1 text-sm border rounded ' + (page <= 1 ? 'text-gray-300 border-gray-200 cursor-not-allowed' : 'text-gray-700 hover:bg-gray-50') + '">&larr;</button>';
const startPage = Math.max(1, page - 2);
const endPage = Math.min(totalPages, page + 2);
for (let i = startPage; i <= endPage; i++) {
html += '<button type="button" onclick="goToPage(' + i + ')" class="px-3 py-1 text-sm border rounded ' + (i === page ? 'bg-blue-600 text-white border-blue-600' : 'text-gray-700 border-gray-300 hover:bg-gray-50') + '">' + i + '</button>';
}
html += '<button type="button" onclick="goToPage(' + (page + 1) + ')" ' + (page >= totalPages ? 'disabled' : '') + ' class="px-3 py-1 text-sm border rounded ' + (page >= totalPages ? 'text-gray-300 border-gray-200 cursor-not-allowed' : 'text-gray-700 hover:bg-gray-50') + '">&rarr;</button>';
html += '</div>';
html += '</div>';
}
root.innerHTML = html; root.innerHTML = html;
const authorInput = document.getElementById('projects-author-filter');
if (authorInput) {
authorInput.addEventListener('input', function(e) {
authorSearch = (e.target.value || '').trim();
currentPage = 1;
loadProjects();
});
}
}
function goToPage(page) {
if (page < 1) return;
currentPage = page;
loadProjects();
} }
async function createProject() { async function createProject() {
@@ -223,6 +321,7 @@ loadProjects();
document.getElementById('projects-search').addEventListener('input', function(e) { document.getElementById('projects-search').addEventListener('input', function(e) {
projectsSearch = (e.target.value || '').trim(); projectsSearch = (e.target.value || '').trim();
currentPage = 1;
loadProjects(); loadProjects();
}); });
</script> </script>