Add projects table controls and sync status tab with app version
This commit is contained in:
102
cmd/qfs/main.go
102
cmd/qfs/main.go
@@ -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{
|
||||||
@@ -1096,7 +1179,13 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
"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)
|
||||||
|
|||||||
@@ -20,11 +20,13 @@ type SyncHandler struct {
|
|||||||
localDB *localdb.LocalDB
|
localDB *localdb.LocalDB
|
||||||
syncService *sync.Service
|
syncService *sync.Service
|
||||||
connMgr *db.ConnectionManager
|
connMgr *db.ConnectionManager
|
||||||
|
autoSyncInterval time.Duration
|
||||||
|
onlineGraceFactor float64
|
||||||
tmpl *template.Template
|
tmpl *template.Template
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewSyncHandler creates a new sync handler
|
// NewSyncHandler creates a new sync handler
|
||||||
func NewSyncHandler(localDB *localdb.LocalDB, syncService *sync.Service, 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
|
||||||
@@ -42,6 +44,8 @@ func NewSyncHandler(localDB *localdb.LocalDB, syncService *sync.Service, connMgr
|
|||||||
localDB: localDB,
|
localDB: localDB,
|
||||||
syncService: syncService,
|
syncService: syncService,
|
||||||
connMgr: connMgr,
|
connMgr: connMgr,
|
||||||
|
autoSyncInterval: autoSyncInterval,
|
||||||
|
onlineGraceFactor: 1.10,
|
||||||
tmpl: tmpl,
|
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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
8
migrations/010_add_pricelist_sync_status.sql
Normal file
8
migrations/010_add_pricelist_sync_status.sql
Normal 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)
|
||||||
|
);
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE qt_pricelist_sync_status
|
||||||
|
ADD COLUMN IF NOT EXISTS app_version VARCHAR(64) NULL;
|
||||||
@@ -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>
|
||||||
|
`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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') + '">←</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') + '">→</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>
|
||||||
|
|||||||
Reference in New Issue
Block a user