Deduplicate configuration revisions and update revisions UI
This commit is contained in:
@@ -28,6 +28,7 @@
|
|||||||
- `config.example.yaml` остаётся единственным шаблоном конфигурации в репо.
|
- `config.example.yaml` остаётся единственным шаблоном конфигурации в репо.
|
||||||
- Любые изменения в sync должны сохранять local-first поведение: локальные CRUD не блокируются из-за недоступности MariaDB.
|
- Любые изменения в sync должны сохранять local-first поведение: локальные CRUD не блокируются из-за недоступности MariaDB.
|
||||||
- CSV-экспорт: имя файла должно содержать **код проекта** (`project.Code`), а не название (`project.Name`). Формат: `YYYY-MM-DD (КодПроекта) ИмяКонфигурации Артикул.csv`.
|
- CSV-экспорт: имя файла должно содержать **код проекта** (`project.Code`), а не название (`project.Name`). Формат: `YYYY-MM-DD (КодПроекта) ИмяКонфигурации Артикул.csv`.
|
||||||
|
- UI: во всех breadcrumbs длинные названия спецификаций/конфигураций сокращать до 16 символов с многоточием.
|
||||||
|
|
||||||
## Key SQLite Data
|
## Key SQLite Data
|
||||||
- `connection_settings`
|
- `connection_settings`
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestRunLocalMigrationsBackfillsExistingConfigurations(t *testing.T) {
|
func TestRunLocalMigrationsBackfillsExistingConfigurations(t *testing.T) {
|
||||||
@@ -125,3 +127,129 @@ func TestRunLocalMigrationsFixesPricelistVersionUniqueIndex(t *testing.T) {
|
|||||||
t.Fatalf("expected 2 pricelists, got %d", count)
|
t.Fatalf("expected 2 pricelists, got %d", count)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRunLocalMigrationsDeduplicatesConfigurationVersionsBySpecAndPrice(t *testing.T) {
|
||||||
|
dbPath := filepath.Join(t.TempDir(), "versions_dedup.db")
|
||||||
|
|
||||||
|
local, err := New(dbPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open localdb: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = local.Close() })
|
||||||
|
|
||||||
|
cfg := &LocalConfiguration{
|
||||||
|
UUID: "dedup-cfg",
|
||||||
|
Name: "Dedup",
|
||||||
|
Items: LocalConfigItems{{LotName: "CPU_A", Quantity: 1, UnitPrice: 100}},
|
||||||
|
ServerCount: 1,
|
||||||
|
SyncStatus: "pending",
|
||||||
|
OriginalUsername: "tester",
|
||||||
|
IsActive: true,
|
||||||
|
}
|
||||||
|
if err := local.SaveConfiguration(cfg); err != nil {
|
||||||
|
t.Fatalf("save seed config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
baseV1Data, err := BuildConfigurationSnapshot(cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("build v1 snapshot: %v", err)
|
||||||
|
}
|
||||||
|
baseV1 := LocalConfigurationVersion{
|
||||||
|
ID: uuid.NewString(),
|
||||||
|
ConfigurationUUID: cfg.UUID,
|
||||||
|
VersionNo: 1,
|
||||||
|
Data: baseV1Data,
|
||||||
|
AppVersion: "test",
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
if err := local.DB().Create(&baseV1).Error; err != nil {
|
||||||
|
t.Fatalf("insert base v1: %v", err)
|
||||||
|
}
|
||||||
|
if err := local.DB().Model(&LocalConfiguration{}).
|
||||||
|
Where("uuid = ?", cfg.UUID).
|
||||||
|
Update("current_version_id", baseV1.ID).Error; err != nil {
|
||||||
|
t.Fatalf("set current_version_id to v1: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
v2 := LocalConfigurationVersion{
|
||||||
|
ID: uuid.NewString(),
|
||||||
|
ConfigurationUUID: cfg.UUID,
|
||||||
|
VersionNo: 2,
|
||||||
|
Data: baseV1.Data,
|
||||||
|
AppVersion: "test",
|
||||||
|
CreatedAt: time.Now().Add(1 * time.Second),
|
||||||
|
}
|
||||||
|
if err := local.DB().Create(&v2).Error; err != nil {
|
||||||
|
t.Fatalf("insert duplicate v2: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
modified := *cfg
|
||||||
|
modified.Items = LocalConfigItems{{LotName: "CPU_A", Quantity: 2, UnitPrice: 100}}
|
||||||
|
total := modified.Items.Total()
|
||||||
|
modified.TotalPrice = &total
|
||||||
|
modified.UpdatedAt = time.Now()
|
||||||
|
v3Data, err := BuildConfigurationSnapshot(&modified)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("build v3 snapshot: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
v3 := LocalConfigurationVersion{
|
||||||
|
ID: uuid.NewString(),
|
||||||
|
ConfigurationUUID: cfg.UUID,
|
||||||
|
VersionNo: 3,
|
||||||
|
Data: v3Data,
|
||||||
|
AppVersion: "test",
|
||||||
|
CreatedAt: time.Now().Add(2 * time.Second),
|
||||||
|
}
|
||||||
|
if err := local.DB().Create(&v3).Error; err != nil {
|
||||||
|
t.Fatalf("insert v3: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
v4 := LocalConfigurationVersion{
|
||||||
|
ID: uuid.NewString(),
|
||||||
|
ConfigurationUUID: cfg.UUID,
|
||||||
|
VersionNo: 4,
|
||||||
|
Data: v3Data,
|
||||||
|
AppVersion: "test",
|
||||||
|
CreatedAt: time.Now().Add(3 * time.Second),
|
||||||
|
}
|
||||||
|
if err := local.DB().Create(&v4).Error; err != nil {
|
||||||
|
t.Fatalf("insert duplicate v4: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := local.DB().Model(&LocalConfiguration{}).
|
||||||
|
Where("uuid = ?", cfg.UUID).
|
||||||
|
Update("current_version_id", v4.ID).Error; err != nil {
|
||||||
|
t.Fatalf("point current_version_id to duplicate v4: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := local.DB().Where("id = ?", "2026_02_19_configuration_versions_dedup_spec_price").
|
||||||
|
Delete(&LocalSchemaMigration{}).Error; err != nil {
|
||||||
|
t.Fatalf("delete dedup migration record: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := runLocalMigrations(local.DB()); err != nil {
|
||||||
|
t.Fatalf("rerun local migrations: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var versions []LocalConfigurationVersion
|
||||||
|
if err := local.DB().Where("configuration_uuid = ?", cfg.UUID).
|
||||||
|
Order("version_no ASC").
|
||||||
|
Find(&versions).Error; err != nil {
|
||||||
|
t.Fatalf("load versions after dedup: %v", err)
|
||||||
|
}
|
||||||
|
if len(versions) != 2 {
|
||||||
|
t.Fatalf("expected 2 versions after dedup, got %d", len(versions))
|
||||||
|
}
|
||||||
|
if versions[0].VersionNo != 1 || versions[1].VersionNo != 3 {
|
||||||
|
t.Fatalf("expected kept version numbers [1,3], got [%d,%d]", versions[0].VersionNo, versions[1].VersionNo)
|
||||||
|
}
|
||||||
|
|
||||||
|
var after LocalConfiguration
|
||||||
|
if err := local.DB().Where("uuid = ?", cfg.UUID).First(&after).Error; err != nil {
|
||||||
|
t.Fatalf("load config after dedup: %v", err)
|
||||||
|
}
|
||||||
|
if after.CurrentVersionID == nil || *after.CurrentVersionID != v3.ID {
|
||||||
|
t.Fatalf("expected current_version_id to point to kept latest version v3")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -103,6 +103,11 @@ var localMigrations = []localMigration{
|
|||||||
name: "Allow NULL project names in local_projects",
|
name: "Allow NULL project names in local_projects",
|
||||||
run: allowLocalProjectNameNull,
|
run: allowLocalProjectNameNull,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "2026_02_19_configuration_versions_dedup_spec_price",
|
||||||
|
name: "Deduplicate configuration revisions by spec+price",
|
||||||
|
run: deduplicateConfigurationVersionsBySpecAndPrice,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
func runLocalMigrations(db *gorm.DB) error {
|
func runLocalMigrations(db *gorm.DB) error {
|
||||||
@@ -428,6 +433,92 @@ func chooseNonZeroTime(candidate time.Time, fallback time.Time) time.Time {
|
|||||||
return candidate
|
return candidate
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func deduplicateConfigurationVersionsBySpecAndPrice(tx *gorm.DB) error {
|
||||||
|
var configs []LocalConfiguration
|
||||||
|
if err := tx.Select("uuid", "current_version_id").Find(&configs).Error; err != nil {
|
||||||
|
return fmt.Errorf("load configurations for revision deduplication: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var removedTotal int
|
||||||
|
for i := range configs {
|
||||||
|
cfg := configs[i]
|
||||||
|
|
||||||
|
var versions []LocalConfigurationVersion
|
||||||
|
if err := tx.Where("configuration_uuid = ?", cfg.UUID).
|
||||||
|
Order("version_no ASC, created_at ASC").
|
||||||
|
Find(&versions).Error; err != nil {
|
||||||
|
return fmt.Errorf("load versions for %s: %w", cfg.UUID, err)
|
||||||
|
}
|
||||||
|
if len(versions) < 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteIDs := make([]string, 0)
|
||||||
|
deleteSet := make(map[string]struct{})
|
||||||
|
kept := make([]LocalConfigurationVersion, 0, len(versions))
|
||||||
|
var prevKey string
|
||||||
|
hasPrev := false
|
||||||
|
|
||||||
|
for _, version := range versions {
|
||||||
|
snapshotCfg, err := DecodeConfigurationSnapshot(version.Data)
|
||||||
|
if err != nil {
|
||||||
|
// Keep malformed snapshots untouched and reset chain to avoid accidental removals.
|
||||||
|
kept = append(kept, version)
|
||||||
|
hasPrev = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
key, err := BuildConfigurationSpecPriceFingerprint(snapshotCfg)
|
||||||
|
if err != nil {
|
||||||
|
kept = append(kept, version)
|
||||||
|
hasPrev = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if !hasPrev || key != prevKey {
|
||||||
|
kept = append(kept, version)
|
||||||
|
prevKey = key
|
||||||
|
hasPrev = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteIDs = append(deleteIDs, version.ID)
|
||||||
|
deleteSet[version.ID] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(deleteIDs) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Where("id IN ?", deleteIDs).Delete(&LocalConfigurationVersion{}).Error; err != nil {
|
||||||
|
return fmt.Errorf("delete duplicate versions for %s: %w", cfg.UUID, err)
|
||||||
|
}
|
||||||
|
removedTotal += len(deleteIDs)
|
||||||
|
|
||||||
|
latestKeptID := kept[len(kept)-1].ID
|
||||||
|
if cfg.CurrentVersionID == nil || *cfg.CurrentVersionID == "" {
|
||||||
|
if err := tx.Model(&LocalConfiguration{}).
|
||||||
|
Where("uuid = ?", cfg.UUID).
|
||||||
|
Update("current_version_id", latestKeptID).Error; err != nil {
|
||||||
|
return fmt.Errorf("set missing current_version_id for %s: %w", cfg.UUID, err)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, deleted := deleteSet[*cfg.CurrentVersionID]; deleted {
|
||||||
|
if err := tx.Model(&LocalConfiguration{}).
|
||||||
|
Where("uuid = ?", cfg.UUID).
|
||||||
|
Update("current_version_id", latestKeptID).Error; err != nil {
|
||||||
|
return fmt.Errorf("repair current_version_id for %s: %w", cfg.UUID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if removedTotal > 0 {
|
||||||
|
slog.Info("deduplicated configuration revisions", "removed_versions", removedTotal)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func fixLocalPricelistIndexes(tx *gorm.DB) error {
|
func fixLocalPricelistIndexes(tx *gorm.DB) error {
|
||||||
type indexRow struct {
|
type indexRow struct {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package localdb
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"sort"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -94,3 +95,51 @@ func DecodeConfigurationSnapshot(data string) (*LocalConfiguration, error) {
|
|||||||
OriginalUsername: snapshot.OriginalUsername,
|
OriginalUsername: snapshot.OriginalUsername,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type configurationSpecPriceFingerprint struct {
|
||||||
|
Items []configurationSpecPriceFingerprintItem `json:"items"`
|
||||||
|
ServerCount int `json:"server_count"`
|
||||||
|
TotalPrice *float64 `json:"total_price,omitempty"`
|
||||||
|
CustomPrice *float64 `json:"custom_price,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type configurationSpecPriceFingerprintItem struct {
|
||||||
|
LotName string `json:"lot_name"`
|
||||||
|
Quantity int `json:"quantity"`
|
||||||
|
UnitPrice float64 `json:"unit_price"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildConfigurationSpecPriceFingerprint returns a stable JSON key based on
|
||||||
|
// spec + price fields only, used for revision deduplication.
|
||||||
|
func BuildConfigurationSpecPriceFingerprint(localCfg *LocalConfiguration) (string, error) {
|
||||||
|
items := make([]configurationSpecPriceFingerprintItem, 0, len(localCfg.Items))
|
||||||
|
for _, item := range localCfg.Items {
|
||||||
|
items = append(items, configurationSpecPriceFingerprintItem{
|
||||||
|
LotName: item.LotName,
|
||||||
|
Quantity: item.Quantity,
|
||||||
|
UnitPrice: item.UnitPrice,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
sort.Slice(items, func(i, j int) bool {
|
||||||
|
if items[i].LotName != items[j].LotName {
|
||||||
|
return items[i].LotName < items[j].LotName
|
||||||
|
}
|
||||||
|
if items[i].Quantity != items[j].Quantity {
|
||||||
|
return items[i].Quantity < items[j].Quantity
|
||||||
|
}
|
||||||
|
return items[i].UnitPrice < items[j].UnitPrice
|
||||||
|
})
|
||||||
|
|
||||||
|
payload := configurationSpecPriceFingerprint{
|
||||||
|
Items: items,
|
||||||
|
ServerCount: localCfg.ServerCount,
|
||||||
|
TotalPrice: localCfg.TotalPrice,
|
||||||
|
CustomPrice: localCfg.CustomPrice,
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("marshal spec+price fingerprint: %w", err)
|
||||||
|
}
|
||||||
|
return string(raw), nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -995,6 +995,22 @@ func (s *LocalConfigurationService) saveWithVersionAndPending(localCfg *localdb.
|
|||||||
return fmt.Errorf("lock configuration row: %w", err)
|
return fmt.Errorf("lock configuration row: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if operation == "update" {
|
||||||
|
currentVersion, err := s.loadCurrentVersionTx(tx, &locked)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("load current version before save: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sameRevisionContent, err := s.hasSameRevisionContent(localCfg, currentVersion)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("compare revision content: %w", err)
|
||||||
|
}
|
||||||
|
if sameRevisionContent {
|
||||||
|
cfg = localdb.LocalToConfiguration(&locked)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if err := tx.Save(localCfg).Error; err != nil {
|
if err := tx.Save(localCfg).Error; err != nil {
|
||||||
return fmt.Errorf("save local configuration: %w", err)
|
return fmt.Errorf("save local configuration: %w", err)
|
||||||
}
|
}
|
||||||
@@ -1029,6 +1045,41 @@ func (s *LocalConfigurationService) saveWithVersionAndPending(localCfg *localdb.
|
|||||||
return cfg, nil
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *LocalConfigurationService) loadCurrentVersionTx(tx *gorm.DB, localCfg *localdb.LocalConfiguration) (*localdb.LocalConfigurationVersion, error) {
|
||||||
|
var version localdb.LocalConfigurationVersion
|
||||||
|
if localCfg.CurrentVersionID != nil && *localCfg.CurrentVersionID != "" {
|
||||||
|
if err := tx.Where("id = ?", *localCfg.CurrentVersionID).First(&version).Error; err == nil {
|
||||||
|
return &version, nil
|
||||||
|
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Where("configuration_uuid = ?", localCfg.UUID).
|
||||||
|
Order("version_no DESC").
|
||||||
|
First(&version).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &version, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *LocalConfigurationService) hasSameRevisionContent(localCfg *localdb.LocalConfiguration, currentVersion *localdb.LocalConfigurationVersion) (bool, error) {
|
||||||
|
currentSnapshotCfg, err := s.decodeConfigurationSnapshot(currentVersion.Data)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("decode current version snapshot: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
currentFingerprint, err := localdb.BuildConfigurationSpecPriceFingerprint(currentSnapshotCfg)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("build current snapshot fingerprint: %w", err)
|
||||||
|
}
|
||||||
|
nextFingerprint, err := localdb.BuildConfigurationSpecPriceFingerprint(localCfg)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("build next snapshot fingerprint: %w", err)
|
||||||
|
}
|
||||||
|
return currentFingerprint == nextFingerprint, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *LocalConfigurationService) appendVersionTx(
|
func (s *LocalConfigurationService) appendVersionTx(
|
||||||
tx *gorm.DB,
|
tx *gorm.DB,
|
||||||
localCfg *localdb.LocalConfiguration,
|
localCfg *localdb.LocalConfiguration,
|
||||||
|
|||||||
@@ -27,8 +27,12 @@ func TestSaveCreatesNewVersionAndUpdatesCurrentPointer(t *testing.T) {
|
|||||||
t.Fatalf("create config: %v", err)
|
t.Fatalf("create config: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := service.RenameNoAuth(created.UUID, "v2"); err != nil {
|
if _, err := service.UpdateNoAuth(created.UUID, &CreateConfigRequest{
|
||||||
t.Fatalf("rename config: %v", err)
|
Name: "v1",
|
||||||
|
Items: models.ConfigItems{{LotName: "CPU_A", Quantity: 2, UnitPrice: 1000}},
|
||||||
|
ServerCount: 1,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("update config: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
versions := loadVersions(t, local, created.UUID)
|
versions := loadVersions(t, local, created.UUID)
|
||||||
@@ -60,8 +64,12 @@ func TestRollbackCreatesNewVersionWithTargetData(t *testing.T) {
|
|||||||
t.Fatalf("create config: %v", err)
|
t.Fatalf("create config: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := service.RenameNoAuth(created.UUID, "changed"); err != nil {
|
if _, err := service.UpdateNoAuth(created.UUID, &CreateConfigRequest{
|
||||||
t.Fatalf("rename config: %v", err)
|
Name: "base",
|
||||||
|
Items: models.ConfigItems{{LotName: "RAM_A", Quantity: 3, UnitPrice: 100}},
|
||||||
|
ServerCount: 1,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("update config: %v", err)
|
||||||
}
|
}
|
||||||
if _, err := service.RollbackToVersionWithNote(created.UUID, 1, "tester", "test rollback"); err != nil {
|
if _, err := service.RollbackToVersionWithNote(created.UUID, 1, "tester", "test rollback"); err != nil {
|
||||||
t.Fatalf("rollback to v1: %v", err)
|
t.Fatalf("rollback to v1: %v", err)
|
||||||
@@ -79,6 +87,56 @@ func TestRollbackCreatesNewVersionWithTargetData(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUpdateNoAuthSkipsRevisionWhenSpecAndPriceUnchanged(t *testing.T) {
|
||||||
|
service, local := newLocalConfigServiceForTest(t)
|
||||||
|
|
||||||
|
created, err := service.Create("tester", &CreateConfigRequest{
|
||||||
|
Name: "dedupe",
|
||||||
|
Items: models.ConfigItems{{LotName: "CPU_A", Quantity: 1, UnitPrice: 1000}},
|
||||||
|
ServerCount: 1,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = service.UpdateNoAuth(created.UUID, &CreateConfigRequest{
|
||||||
|
Name: "dedupe",
|
||||||
|
Items: models.ConfigItems{{LotName: "CPU_A", Quantity: 2, UnitPrice: 1000}},
|
||||||
|
ServerCount: 1,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("first update config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = service.UpdateNoAuth(created.UUID, &CreateConfigRequest{
|
||||||
|
Name: "dedupe",
|
||||||
|
Items: models.ConfigItems{{LotName: "CPU_A", Quantity: 2, UnitPrice: 1000}},
|
||||||
|
ServerCount: 1,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("second update config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
versions := loadVersions(t, local, created.UUID)
|
||||||
|
if len(versions) != 2 {
|
||||||
|
t.Fatalf("expected 2 versions (create + first update), got %d", len(versions))
|
||||||
|
}
|
||||||
|
if versions[1].VersionNo != 2 {
|
||||||
|
t.Fatalf("expected latest version_no=2, got %d", versions[1].VersionNo)
|
||||||
|
}
|
||||||
|
|
||||||
|
var pendingCount int64
|
||||||
|
if err := local.DB().
|
||||||
|
Table("pending_changes").
|
||||||
|
Where("entity_type = ? AND entity_uuid = ?", "configuration", created.UUID).
|
||||||
|
Count(&pendingCount).Error; err != nil {
|
||||||
|
t.Fatalf("count pending changes: %v", err)
|
||||||
|
}
|
||||||
|
if pendingCount != 2 {
|
||||||
|
t.Fatalf("expected 2 pending changes (create + first update), got %d", pendingCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestAppendOnlyInvariantOldRowsUnchanged(t *testing.T) {
|
func TestAppendOnlyInvariantOldRowsUnchanged(t *testing.T) {
|
||||||
service, local := newLocalConfigServiceForTest(t)
|
service, local := newLocalConfigServiceForTest(t)
|
||||||
|
|
||||||
@@ -97,8 +155,12 @@ func TestAppendOnlyInvariantOldRowsUnchanged(t *testing.T) {
|
|||||||
}
|
}
|
||||||
v1Before := versionsBefore[0]
|
v1Before := versionsBefore[0]
|
||||||
|
|
||||||
if _, err := service.RenameNoAuth(created.UUID, "after-rename"); err != nil {
|
if _, err := service.UpdateNoAuth(created.UUID, &CreateConfigRequest{
|
||||||
t.Fatalf("rename config: %v", err)
|
Name: "initial",
|
||||||
|
Items: models.ConfigItems{{LotName: "SSD_A", Quantity: 2, UnitPrice: 300}},
|
||||||
|
ServerCount: 1,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("update config: %v", err)
|
||||||
}
|
}
|
||||||
if _, err := service.RollbackToVersion(created.UUID, 1, "tester"); err != nil {
|
if _, err := service.RollbackToVersion(created.UUID, 1, "tester"); err != nil {
|
||||||
t.Fatalf("rollback: %v", err)
|
t.Fatalf("rollback: %v", err)
|
||||||
@@ -144,7 +206,7 @@ func TestConcurrentSaveNoDuplicateVersionNumbers(t *testing.T) {
|
|||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
<-start
|
<-start
|
||||||
if err := renameWithRetry(service, created.UUID, fmt.Sprintf("name-%d", i)); err != nil {
|
if err := updateWithRetry(service, created.UUID, i+2); err != nil {
|
||||||
errCh <- err
|
errCh <- err
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
@@ -264,10 +326,14 @@ func loadVersions(t *testing.T, local *localdb.LocalDB, configurationUUID string
|
|||||||
return versions
|
return versions
|
||||||
}
|
}
|
||||||
|
|
||||||
func renameWithRetry(service *LocalConfigurationService, uuid string, name string) error {
|
func updateWithRetry(service *LocalConfigurationService, uuid string, quantity int) error {
|
||||||
var lastErr error
|
var lastErr error
|
||||||
for i := 0; i < 6; i++ {
|
for i := 0; i < 6; i++ {
|
||||||
_, err := service.RenameNoAuth(uuid, name)
|
_, err := service.UpdateNoAuth(uuid, &CreateConfigRequest{
|
||||||
|
Name: "base",
|
||||||
|
Items: models.ConfigItems{{LotName: "NIC_A", Quantity: quantity, UnitPrice: 150}},
|
||||||
|
ServerCount: 1,
|
||||||
|
})
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -278,7 +344,7 @@ func renameWithRetry(service *LocalConfigurationService, uuid string, name strin
|
|||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return fmt.Errorf("rename retries exhausted: %w", lastErr)
|
return fmt.Errorf("update retries exhausted: %w", lastErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRollbackVersionSnapshotJSONMatchesV1(t *testing.T) {
|
func TestRollbackVersionSnapshotJSONMatchesV1(t *testing.T) {
|
||||||
@@ -292,8 +358,12 @@ func TestRollbackVersionSnapshotJSONMatchesV1(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("create config: %v", err)
|
t.Fatalf("create config: %v", err)
|
||||||
}
|
}
|
||||||
if _, err := service.RenameNoAuth(created.UUID, "second"); err != nil {
|
if _, err := service.UpdateNoAuth(created.UUID, &CreateConfigRequest{
|
||||||
t.Fatalf("rename: %v", err)
|
Name: "initial",
|
||||||
|
Items: models.ConfigItems{{LotName: "GPU_A", Quantity: 2, UnitPrice: 2000}},
|
||||||
|
ServerCount: 1,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("update: %v", err)
|
||||||
}
|
}
|
||||||
if _, err := service.RollbackToVersion(created.UUID, 1, "tester"); err != nil {
|
if _, err := service.RollbackToVersion(created.UUID, 1, "tester"); err != nil {
|
||||||
t.Fatalf("rollback: %v", err)
|
t.Fatalf("rollback: %v", err)
|
||||||
|
|||||||
@@ -36,3 +36,6 @@ Implemented strict `lot_category` flow using `pricelist_items.lot_category` only
|
|||||||
Additional fixes (2026-02-11):
|
Additional fixes (2026-02-11):
|
||||||
- Fixed article parsing bug: CPU/GPU parsers were swapped in `internal/article/generator.go`. CPU now uses last token from CPU lot; GPU uses model+memory from `GPU_vendor_model_mem_iface`.
|
- Fixed article parsing bug: CPU/GPU parsers were swapped in `internal/article/generator.go`. CPU now uses last token from CPU lot; GPU uses model+memory from `GPU_vendor_model_mem_iface`.
|
||||||
- Adjusted configurator base tab layout to align labels on the same row (separate label row + input row grid).
|
- Adjusted configurator base tab layout to align labels on the same row (separate label row + input row grid).
|
||||||
|
|
||||||
|
UI rule (2026-02-19):
|
||||||
|
- In all breadcrumbs, truncate long specification/configuration names to 16 characters using ellipsis.
|
||||||
|
|||||||
@@ -42,6 +42,35 @@ function escapeHtml(text) {
|
|||||||
return div.innerHTML;
|
return div.innerHTML;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatMoney(value) {
|
||||||
|
const num = Number(value);
|
||||||
|
if (!Number.isFinite(num)) return '—';
|
||||||
|
return '$\u00A0' + num.toLocaleString('ru-RU', {minimumFractionDigits: 0, maximumFractionDigits: 2});
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseVersionSnapshot(version) {
|
||||||
|
try {
|
||||||
|
const raw = typeof version.data === 'string' ? version.data : '';
|
||||||
|
if (!raw) return { article: '—', price: null, serverCount: 1 };
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
return {
|
||||||
|
article: parsed.article || '—',
|
||||||
|
price: typeof parsed.total_price === 'number' ? parsed.total_price : null,
|
||||||
|
serverCount: Number.isFinite(Number(parsed.server_count)) && Number(parsed.server_count) > 0
|
||||||
|
? Number(parsed.server_count)
|
||||||
|
: 1
|
||||||
|
};
|
||||||
|
} catch (_) {
|
||||||
|
return { article: '—', price: null, serverCount: 1 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncateBreadcrumbSpecName(name) {
|
||||||
|
const maxLength = 16;
|
||||||
|
if (!name || name.length <= maxLength) return name;
|
||||||
|
return name.slice(0, maxLength - 1) + '…';
|
||||||
|
}
|
||||||
|
|
||||||
async function loadConfigInfo() {
|
async function loadConfigInfo() {
|
||||||
try {
|
try {
|
||||||
const resp = await fetch('/api/configs/' + configUUID);
|
const resp = await fetch('/api/configs/' + configUUID);
|
||||||
@@ -52,7 +81,10 @@ async function loadConfigInfo() {
|
|||||||
}
|
}
|
||||||
configData = await resp.json();
|
configData = await resp.json();
|
||||||
|
|
||||||
document.getElementById('breadcrumb-config').textContent = configData.name || 'Конфигурация';
|
const fullConfigName = configData.name || 'Конфигурация';
|
||||||
|
const configBreadcrumbEl = document.getElementById('breadcrumb-config');
|
||||||
|
configBreadcrumbEl.textContent = truncateBreadcrumbSpecName(fullConfigName);
|
||||||
|
configBreadcrumbEl.title = fullConfigName;
|
||||||
document.getElementById('breadcrumb-config-link').href = '/configurator?uuid=' + configUUID;
|
document.getElementById('breadcrumb-config-link').href = '/configurator?uuid=' + configUUID;
|
||||||
|
|
||||||
if (configData.project_uuid) {
|
if (configData.project_uuid) {
|
||||||
@@ -114,14 +146,16 @@ function renderVersions(versions) {
|
|||||||
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">Дата</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">Автор</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">Цена</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-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></thead><tbody class="divide-y">';
|
||||||
|
|
||||||
versions.forEach((v, idx) => {
|
versions.forEach((v, idx) => {
|
||||||
const date = new Date(v.created_at).toLocaleString('ru-RU');
|
const date = new Date(v.created_at).toLocaleString('ru-RU');
|
||||||
const author = v.created_by || '—';
|
const author = v.created_by || '—';
|
||||||
const note = v.change_note || '—';
|
const snapshot = parseVersionSnapshot(v);
|
||||||
const isCurrent = idx === 0;
|
const isCurrent = idx === 0;
|
||||||
|
|
||||||
html += '<tr class="hover:bg-gray-50' + (isCurrent ? ' bg-blue-50' : '') + '">';
|
html += '<tr class="hover:bg-gray-50' + (isCurrent ? ' bg-blue-50' : '') + '">';
|
||||||
@@ -131,7 +165,9 @@ function renderVersions(versions) {
|
|||||||
html += '</td>';
|
html += '</td>';
|
||||||
html += '<td class="px-4 py-3 text-sm text-gray-500">' + escapeHtml(date) + '</td>';
|
html += '<td class="px-4 py-3 text-sm text-gray-500">' + escapeHtml(date) + '</td>';
|
||||||
html += '<td class="px-4 py-3 text-sm text-gray-500">' + escapeHtml(author) + '</td>';
|
html += '<td class="px-4 py-3 text-sm text-gray-500">' + escapeHtml(author) + '</td>';
|
||||||
html += '<td class="px-4 py-3 text-sm text-gray-500">' + escapeHtml(note) + '</td>';
|
html += '<td class="px-4 py-3 text-sm text-gray-500">' + escapeHtml(snapshot.article) + '</td>';
|
||||||
|
html += '<td class="px-4 py-3 text-sm text-gray-500">' + formatMoney(snapshot.price) + '</td>';
|
||||||
|
html += '<td class="px-4 py-3 text-sm text-gray-500">' + escapeHtml(String(snapshot.serverCount)) + '</td>';
|
||||||
html += '<td class="px-4 py-3 text-sm text-right space-x-2">';
|
html += '<td class="px-4 py-3 text-sm text-right space-x-2">';
|
||||||
|
|
||||||
// Open in configurator (readonly view)
|
// Open in configurator (readonly view)
|
||||||
|
|||||||
@@ -401,18 +401,28 @@ function updateConfigBreadcrumbs() {
|
|||||||
}
|
}
|
||||||
codeEl.textContent = code;
|
codeEl.textContent = code;
|
||||||
variantEl.textContent = variant;
|
variantEl.textContent = variant;
|
||||||
configEl.textContent = configName || 'Конфигурация';
|
const fullConfigName = configName || 'Конфигурация';
|
||||||
|
configEl.textContent = truncateBreadcrumbSpecName(fullConfigName);
|
||||||
|
configEl.title = fullConfigName;
|
||||||
versionEl.textContent = 'v' + (currentVersionNo || 1);
|
versionEl.textContent = 'v' + (currentVersionNo || 1);
|
||||||
const configNameLinkEl = document.getElementById('breadcrumb-config-name-link');
|
const configNameLinkEl = document.getElementById('breadcrumb-config-name-link');
|
||||||
if (configNameLinkEl && configUUID) {
|
if (configNameLinkEl && configUUID) {
|
||||||
configNameLinkEl.href = '/configs/' + configUUID + '/revisions';
|
configNameLinkEl.href = '/configs/' + configUUID + '/revisions';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function truncateBreadcrumbSpecName(name) {
|
||||||
|
const maxLength = 16;
|
||||||
|
if (!name || name.length <= maxLength) return name;
|
||||||
|
return name.slice(0, maxLength - 1) + '…';
|
||||||
|
}
|
||||||
let currentTab = 'base';
|
let currentTab = 'base';
|
||||||
let allComponents = [];
|
let allComponents = [];
|
||||||
let cart = [];
|
let cart = [];
|
||||||
let categoryOrderMap = {}; // Category code -> display_order mapping
|
let categoryOrderMap = {}; // Category code -> display_order mapping
|
||||||
let autoSaveTimeout = null; // Timeout for debounced autosave
|
let autoSaveTimeout = null; // Timeout for debounced autosave
|
||||||
|
let hasUnsavedChanges = false;
|
||||||
|
let exitSaveStarted = false;
|
||||||
let serverCount = 1; // Server count for the configuration
|
let serverCount = 1; // Server count for the configuration
|
||||||
let serverModelForQuote = '';
|
let serverModelForQuote = '';
|
||||||
let supportCode = '';
|
let supportCode = '';
|
||||||
@@ -1890,13 +1900,128 @@ function getCurrentArticle() {
|
|||||||
return currentArticle || '';
|
return currentArticle || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getAutosaveStorageKey() {
|
||||||
|
return `qf_config_autosave_${configUUID || 'default'}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSavePayload() {
|
||||||
|
const customPriceInput = document.getElementById('custom-price-input');
|
||||||
|
const customPriceValue = parseFloat(customPriceInput.value);
|
||||||
|
const customPrice = customPriceValue > 0 ? customPriceValue : null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: configName,
|
||||||
|
items: cart,
|
||||||
|
custom_price: customPrice,
|
||||||
|
notes: '',
|
||||||
|
server_count: serverCount,
|
||||||
|
server_model: serverModelForQuote,
|
||||||
|
support_code: supportCode,
|
||||||
|
article: getCurrentArticle(),
|
||||||
|
pricelist_id: selectedPricelistIds.estimate,
|
||||||
|
only_in_stock: onlyInStock
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistAutosaveDraft() {
|
||||||
|
if (!configUUID) return;
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem(getAutosaveStorageKey(), JSON.stringify({
|
||||||
|
payload: buildSavePayload(),
|
||||||
|
saved_at: Date.now()
|
||||||
|
}));
|
||||||
|
} catch (_) {
|
||||||
|
// ignore storage failures
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearAutosaveDraft() {
|
||||||
|
try {
|
||||||
|
sessionStorage.removeItem(getAutosaveStorageKey());
|
||||||
|
} catch (_) {
|
||||||
|
// ignore storage failures
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreAutosaveDraftIfAny() {
|
||||||
|
if (!configUUID) return;
|
||||||
|
let raw = null;
|
||||||
|
try {
|
||||||
|
raw = sessionStorage.getItem(getAutosaveStorageKey());
|
||||||
|
} catch (_) {
|
||||||
|
raw = null;
|
||||||
|
}
|
||||||
|
if (!raw) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
const payload = parsed && parsed.payload ? parsed.payload : null;
|
||||||
|
if (!payload) return;
|
||||||
|
|
||||||
|
if (Array.isArray(payload.items)) {
|
||||||
|
cart = payload.items.map(item => ({
|
||||||
|
lot_name: item.lot_name,
|
||||||
|
quantity: item.quantity,
|
||||||
|
unit_price: item.unit_price,
|
||||||
|
estimate_price: item.unit_price,
|
||||||
|
warehouse_price: null,
|
||||||
|
competitor_price: null,
|
||||||
|
description: item.description || '',
|
||||||
|
category: item.category || getCategoryFromLotName(item.lot_name)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
if (typeof payload.server_count === 'number' && payload.server_count > 0) {
|
||||||
|
serverCount = payload.server_count;
|
||||||
|
const serverCountInput = document.getElementById('server-count');
|
||||||
|
if (serverCountInput) serverCountInput.value = serverCount;
|
||||||
|
const totalServerCount = document.getElementById('total-server-count');
|
||||||
|
if (totalServerCount) totalServerCount.textContent = serverCount;
|
||||||
|
}
|
||||||
|
serverModelForQuote = payload.server_model || serverModelForQuote;
|
||||||
|
supportCode = payload.support_code || supportCode;
|
||||||
|
currentArticle = payload.article || currentArticle;
|
||||||
|
selectedPricelistIds.estimate = payload.pricelist_id || selectedPricelistIds.estimate;
|
||||||
|
onlyInStock = Boolean(payload.only_in_stock);
|
||||||
|
|
||||||
|
const customPriceInput = document.getElementById('custom-price-input');
|
||||||
|
if (customPriceInput) {
|
||||||
|
if (typeof payload.custom_price === 'number' && payload.custom_price > 0) {
|
||||||
|
customPriceInput.value = payload.custom_price.toFixed(2);
|
||||||
|
} else {
|
||||||
|
customPriceInput.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
hasUnsavedChanges = true;
|
||||||
|
} catch (_) {
|
||||||
|
// ignore invalid draft
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveConfigOnExit() {
|
||||||
|
if (!configUUID || !hasUnsavedChanges || exitSaveStarted) return;
|
||||||
|
exitSaveStarted = true;
|
||||||
|
try {
|
||||||
|
fetch('/api/configs/' + configUUID, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(buildSavePayload()),
|
||||||
|
keepalive: true
|
||||||
|
});
|
||||||
|
} catch (_) {
|
||||||
|
// best effort save on page exit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function triggerAutoSave() {
|
function triggerAutoSave() {
|
||||||
// Debounce autosave - wait 1 second after last change
|
// Autosave keeps local draft only; server revision is created on Save/Exit.
|
||||||
|
hasUnsavedChanges = true;
|
||||||
if (autoSaveTimeout) {
|
if (autoSaveTimeout) {
|
||||||
clearTimeout(autoSaveTimeout);
|
clearTimeout(autoSaveTimeout);
|
||||||
}
|
}
|
||||||
autoSaveTimeout = setTimeout(() => {
|
autoSaveTimeout = setTimeout(() => {
|
||||||
saveConfig(false); // false = don't show notification
|
persistAutosaveDraft();
|
||||||
}, 1000);
|
}, 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1910,32 +2035,13 @@ async function saveConfig(showNotification = true) {
|
|||||||
|
|
||||||
await refreshPriceLevels({ force: true, noCache: true });
|
await refreshPriceLevels({ force: true, noCache: true });
|
||||||
|
|
||||||
// Get custom price if set
|
|
||||||
const customPriceInput = document.getElementById('custom-price-input');
|
|
||||||
const customPriceValue = parseFloat(customPriceInput.value);
|
|
||||||
const customPrice = customPriceValue > 0 ? customPriceValue : null;
|
|
||||||
|
|
||||||
// Get server count
|
|
||||||
const serverCountValue = serverCount;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const resp = await fetch('/api/configs/' + configUUID, {
|
const resp = await fetch('/api/configs/' + configUUID, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify(buildSavePayload())
|
||||||
name: configName,
|
|
||||||
items: cart,
|
|
||||||
custom_price: customPrice,
|
|
||||||
notes: '',
|
|
||||||
server_count: serverCountValue,
|
|
||||||
server_model: serverModelForQuote,
|
|
||||||
support_code: supportCode,
|
|
||||||
article: getCurrentArticle(),
|
|
||||||
pricelist_id: selectedPricelistIds.estimate,
|
|
||||||
only_in_stock: onlyInStock
|
|
||||||
})
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
@@ -1951,6 +2057,9 @@ async function saveConfig(showNotification = true) {
|
|||||||
const versionEl = document.getElementById('breadcrumb-config-version');
|
const versionEl = document.getElementById('breadcrumb-config-version');
|
||||||
if (versionEl) versionEl.textContent = 'v' + currentVersionNo;
|
if (versionEl) versionEl.textContent = 'v' + currentVersionNo;
|
||||||
}
|
}
|
||||||
|
hasUnsavedChanges = false;
|
||||||
|
clearAutosaveDraft();
|
||||||
|
exitSaveStarted = false;
|
||||||
|
|
||||||
if (showNotification) {
|
if (showNotification) {
|
||||||
showToast('Сохранено', 'success');
|
showToast('Сохранено', 'success');
|
||||||
|
|||||||
@@ -114,38 +114,42 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="rename-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
|
<div id="config-action-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
|
||||||
<div class="bg-white rounded-lg shadow-xl w-full max-w-md mx-4 p-6">
|
<div class="bg-white rounded-lg shadow-xl w-full max-w-md mx-4 p-6">
|
||||||
<h2 class="text-xl font-semibold mb-4">Переименовать квоту</h2>
|
<h2 class="text-xl font-semibold mb-4">Действия с квотой</h2>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">Новое название</label>
|
<label class="block text-sm font-medium text-gray-700 mb-1">Название</label>
|
||||||
<input type="text" id="rename-input"
|
<input type="text" id="config-action-name"
|
||||||
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||||
<input type="hidden" id="rename-uuid">
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<label class="flex items-center gap-2 text-sm text-gray-700">
|
||||||
<div class="flex justify-end space-x-3 mt-6">
|
<input type="checkbox" id="config-action-copy" class="rounded border-gray-300">
|
||||||
<button onclick="closeRenameModal()" class="px-4 py-2 text-gray-600 hover:text-gray-800">Отмена</button>
|
Создать копию
|
||||||
<button onclick="renameConfig()" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">Сохранить</button>
|
</label>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="clone-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
|
|
||||||
<div class="bg-white rounded-lg shadow-xl w-full max-w-md mx-4 p-6">
|
|
||||||
<h2 class="text-xl font-semibold mb-4">Копировать квоту</h2>
|
|
||||||
<div class="space-y-4">
|
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">Название копии</label>
|
<label class="block text-sm font-medium text-gray-700 mb-1">Проект</label>
|
||||||
<input type="text" id="clone-input"
|
<input id="config-action-project-input"
|
||||||
|
list="config-action-project-options"
|
||||||
|
placeholder="Начните вводить проект"
|
||||||
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||||
<input type="hidden" id="clone-uuid">
|
<datalist id="config-action-project-options"></datalist>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Вариант</label>
|
||||||
|
<input id="config-action-variant-input"
|
||||||
|
list="config-action-variant-options"
|
||||||
|
placeholder="Выберите вариант"
|
||||||
|
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||||
|
<datalist id="config-action-variant-options"></datalist>
|
||||||
|
</div>
|
||||||
|
<input type="hidden" id="config-action-uuid">
|
||||||
|
<input type="hidden" id="config-action-current-name">
|
||||||
|
<input type="hidden" id="config-action-current-project">
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-end space-x-3 mt-6">
|
<div class="flex justify-end space-x-3 mt-6">
|
||||||
<button onclick="closeCloneModal()" class="px-4 py-2 text-gray-600 hover:text-gray-800">Отмена</button>
|
<button onclick="closeConfigActionModal()" class="px-4 py-2 text-gray-600 hover:text-gray-800">Отмена</button>
|
||||||
<button onclick="cloneConfig()" class="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700">Копировать</button>
|
<button onclick="saveConfigAction()" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">Сохранить</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -202,30 +206,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="transfer-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
|
|
||||||
<div class="bg-white rounded-lg shadow-xl w-full max-w-md mx-4 p-6">
|
|
||||||
<h2 class="text-xl font-semibold mb-4">Перенести квоту в другой вариант</h2>
|
|
||||||
<div class="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">Целевой вариант</label>
|
|
||||||
<select id="transfer-variant-select" class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
|
||||||
</select>
|
|
||||||
<input type="hidden" id="transfer-config-uuid">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-end space-x-3 mt-6">
|
|
||||||
<button onclick="closeTransferModal()" class="px-4 py-2 text-gray-600 hover:text-gray-800">Отмена</button>
|
|
||||||
<button onclick="transferConfig()" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">Перенести</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const projectUUID = '{{.ProjectUUID}}';
|
const projectUUID = '{{.ProjectUUID}}';
|
||||||
let configStatusMode = 'active';
|
let configStatusMode = 'active';
|
||||||
let project = null;
|
let project = null;
|
||||||
let allConfigs = [];
|
let allConfigs = [];
|
||||||
let projectVariants = [];
|
let projectVariants = [];
|
||||||
|
let projectsCatalog = [];
|
||||||
let variantMenuInitialized = false;
|
let variantMenuInitialized = false;
|
||||||
|
|
||||||
function escapeHtml(text) {
|
function escapeHtml(text) {
|
||||||
@@ -258,12 +245,16 @@ function normalizeVariantLabel(variant) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadVariantsForCode(code) {
|
async function loadVariantsForCode(code) {
|
||||||
if (!code) return;
|
|
||||||
try {
|
try {
|
||||||
const resp = await fetch('/api/projects/all');
|
const resp = await fetch('/api/projects/all');
|
||||||
if (!resp.ok) return;
|
if (!resp.ok) return;
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
const allProjects = Array.isArray(data) ? data : (data.projects || []);
|
const allProjects = Array.isArray(data) ? data : (data.projects || []);
|
||||||
|
projectsCatalog = allProjects.filter(p => p && p.uuid && p.is_active !== false);
|
||||||
|
if (!code) {
|
||||||
|
projectVariants = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
projectVariants = allProjects
|
projectVariants = allProjects
|
||||||
.filter(p => (p.code || '').trim() === code && p.is_active !== false)
|
.filter(p => (p.code || '').trim() === code && p.is_active !== false)
|
||||||
.map(p => ({uuid: p.uuid, variant: (p.variant || '').trim()}));
|
.map(p => ({uuid: p.uuid, variant: (p.variant || '').trim()}));
|
||||||
@@ -401,12 +392,8 @@ function renderConfigs(configs) {
|
|||||||
} else {
|
} else {
|
||||||
html += '<a href="/configs/' + c.uuid + '/revisions" class="text-purple-600 hover:text-purple-800 inline-block" title="Ревизии">';
|
html += '<a href="/configs/' + c.uuid + '/revisions" class="text-purple-600 hover:text-purple-800 inline-block" title="Ревизии">';
|
||||||
html += '<svg class="w-5 h-5" 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></a>';
|
html += '<svg class="w-5 h-5" 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></a>';
|
||||||
html += '<button onclick="openTransferModal(\'' + c.uuid + '\')" class="text-indigo-600 hover:text-indigo-800" title="Перенести в другой вариант">';
|
html += '<button onclick="openConfigActionModal(\'' + c.uuid + '\', \'' + escapeHtml(c.name).replace(/'/g, "\\'") + '\', \'' + (c.project_uuid || projectUUID) + '\')" class="text-indigo-600 hover:text-indigo-800" title="Переименовать / копировать / переместить">';
|
||||||
html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4"></path></svg></button>';
|
html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7h16M4 12h16M4 17h16"></path></svg></button>';
|
||||||
html += '<button onclick="openCloneModal(\'' + c.uuid + '\', \'' + escapeHtml(c.name).replace(/'/g, "\\'") + '\')" class="text-green-600 hover:text-green-800" title="Копировать">';
|
|
||||||
html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path></svg></button>';
|
|
||||||
html += '<button onclick="openRenameModal(\'' + c.uuid + '\', \'' + escapeHtml(c.name).replace(/'/g, "\\'") + '\')" class="text-blue-600 hover:text-blue-800" title="Переименовать">';
|
|
||||||
html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path></svg></button>';
|
|
||||||
html += '<button onclick="deleteConfig(\'' + c.uuid + '\')" class="text-red-600 hover:text-red-800" title="В архив">';
|
html += '<button onclick="deleteConfig(\'' + c.uuid + '\')" class="text-red-600 hover:text-red-800" title="В архив">';
|
||||||
html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path></svg></button>';
|
html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path></svg></button>';
|
||||||
}
|
}
|
||||||
@@ -576,68 +563,227 @@ async function reactivateConfig(uuid) {
|
|||||||
loadConfigs();
|
loadConfigs();
|
||||||
}
|
}
|
||||||
|
|
||||||
function openRenameModal(uuid, currentName) {
|
function projectCodeEntries() {
|
||||||
document.getElementById('rename-uuid').value = uuid;
|
const byCode = new Map();
|
||||||
document.getElementById('rename-input').value = currentName;
|
projectsCatalog.forEach(p => {
|
||||||
document.getElementById('rename-modal').classList.remove('hidden');
|
const code = (p.code || '').trim();
|
||||||
document.getElementById('rename-modal').classList.add('flex');
|
if (!code || byCode.has(code)) return;
|
||||||
}
|
byCode.set(code, {
|
||||||
|
code: code,
|
||||||
function closeRenameModal() {
|
name: (p.name || '').trim()
|
||||||
document.getElementById('rename-modal').classList.add('hidden');
|
});
|
||||||
document.getElementById('rename-modal').classList.remove('flex');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function renameConfig() {
|
|
||||||
const uuid = document.getElementById('rename-uuid').value;
|
|
||||||
const name = document.getElementById('rename-input').value.trim();
|
|
||||||
if (!name) {
|
|
||||||
alert('Введите название');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const resp = await fetch('/api/configs/' + uuid + '/rename', {
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: {'Content-Type': 'application/json'},
|
|
||||||
body: JSON.stringify({name: name})
|
|
||||||
});
|
});
|
||||||
if (!resp.ok) {
|
return Array.from(byCode.values()).sort((a, b) => a.code.localeCompare(b.code, 'ru'));
|
||||||
alert('Не удалось переименовать');
|
}
|
||||||
return;
|
|
||||||
|
function formatProjectAutocompleteValue(entry) {
|
||||||
|
if (!entry) return '';
|
||||||
|
return entry.name ? (entry.code + ' - ' + entry.name) : entry.code;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveProjectCodeFromInput(rawInput) {
|
||||||
|
const input = (rawInput || '').trim();
|
||||||
|
if (!input) return '';
|
||||||
|
const entries = projectCodeEntries();
|
||||||
|
|
||||||
|
const exactCode = entries.find(e => e.code.toLowerCase() === input.toLowerCase());
|
||||||
|
if (exactCode) return exactCode.code;
|
||||||
|
|
||||||
|
const exactDisplayMatches = entries.filter(e => formatProjectAutocompleteValue(e).toLowerCase() === input.toLowerCase());
|
||||||
|
if (exactDisplayMatches.length === 1) return exactDisplayMatches[0].code;
|
||||||
|
|
||||||
|
const byUniqueName = entries.filter(e => (e.name || '').toLowerCase() === input.toLowerCase());
|
||||||
|
if (byUniqueName.length === 1) return byUniqueName[0].code;
|
||||||
|
|
||||||
|
if (input.includes(' - ')) {
|
||||||
|
const codeCandidate = input.split(' - ')[0].trim();
|
||||||
|
const byCandidate = entries.find(e => e.code.toLowerCase() === codeCandidate.toLowerCase());
|
||||||
|
if (byCandidate) return byCandidate.code;
|
||||||
}
|
}
|
||||||
closeRenameModal();
|
|
||||||
loadConfigs();
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function openCloneModal(uuid, currentName) {
|
function populateProjectAutocomplete() {
|
||||||
document.getElementById('clone-uuid').value = uuid;
|
const options = document.getElementById('config-action-project-options');
|
||||||
document.getElementById('clone-input').value = currentName + ' (копия)';
|
options.innerHTML = '';
|
||||||
document.getElementById('clone-modal').classList.remove('hidden');
|
projectCodeEntries().forEach(entry => {
|
||||||
document.getElementById('clone-modal').classList.add('flex');
|
const opt = document.createElement('option');
|
||||||
}
|
opt.value = formatProjectAutocompleteValue(entry);
|
||||||
|
opt.label = entry.code;
|
||||||
function closeCloneModal() {
|
options.appendChild(opt);
|
||||||
document.getElementById('clone-modal').classList.add('hidden');
|
|
||||||
document.getElementById('clone-modal').classList.remove('flex');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function cloneConfig() {
|
|
||||||
const uuid = document.getElementById('clone-uuid').value;
|
|
||||||
const name = document.getElementById('clone-input').value.trim();
|
|
||||||
if (!name) {
|
|
||||||
alert('Введите название');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const resp = await fetch('/api/projects/' + projectUUID + '/configs/' + uuid + '/clone', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {'Content-Type': 'application/json'},
|
|
||||||
body: JSON.stringify({name: name})
|
|
||||||
});
|
});
|
||||||
if (!resp.ok) {
|
}
|
||||||
alert('Не удалось скопировать');
|
|
||||||
|
function variantsForProjectCode(projectCode) {
|
||||||
|
const code = (projectCode || '').trim();
|
||||||
|
if (!code) return [];
|
||||||
|
return projectsCatalog
|
||||||
|
.filter(p => (p.code || '').trim() === code)
|
||||||
|
.map(p => ({uuid: p.uuid, variant: normalizeVariantLabel(p.variant || '')}))
|
||||||
|
.sort((a, b) => a.variant.localeCompare(b.variant, 'ru'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function populateVariantAutocomplete(projectCode, selectedVariantLabel) {
|
||||||
|
const options = document.getElementById('config-action-variant-options');
|
||||||
|
const input = document.getElementById('config-action-variant-input');
|
||||||
|
const variants = variantsForProjectCode(projectCode);
|
||||||
|
options.innerHTML = '';
|
||||||
|
variants.forEach(v => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = v.variant;
|
||||||
|
options.appendChild(opt);
|
||||||
|
});
|
||||||
|
if (selectedVariantLabel) {
|
||||||
|
input.value = selectedVariantLabel;
|
||||||
|
} else if (variants.length === 1) {
|
||||||
|
input.value = variants[0].variant;
|
||||||
|
} else {
|
||||||
|
input.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveTargetProjectUUIDFromInputs() {
|
||||||
|
const projectCode = resolveProjectCodeFromInput(document.getElementById('config-action-project-input').value);
|
||||||
|
if (!projectCode) {
|
||||||
|
return {error: 'Выберите проект из подсказок'};
|
||||||
|
}
|
||||||
|
const variantLabel = normalizeVariantLabel(document.getElementById('config-action-variant-input').value || 'main');
|
||||||
|
const target = projectsCatalog.find(p =>
|
||||||
|
(p.code || '').trim() === projectCode &&
|
||||||
|
normalizeVariantLabel(p.variant || '') === variantLabel
|
||||||
|
);
|
||||||
|
if (!target) {
|
||||||
|
return {error: 'Выберите вариант из подсказок'};
|
||||||
|
}
|
||||||
|
return {uuid: target.uuid};
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncActionModalMode() {
|
||||||
|
const copyCheckbox = document.getElementById('config-action-copy');
|
||||||
|
if (copyCheckbox.checked) {
|
||||||
|
// no-op: copy always uses latest revision
|
||||||
|
} else {
|
||||||
|
// no-op: copy always uses latest revision
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openConfigActionModal(uuid, currentName, currentProjectUUID) {
|
||||||
|
document.getElementById('config-action-uuid').value = uuid;
|
||||||
|
document.getElementById('config-action-current-name').value = currentName;
|
||||||
|
document.getElementById('config-action-current-project').value = currentProjectUUID || projectUUID;
|
||||||
|
document.getElementById('config-action-name').value = currentName;
|
||||||
|
document.getElementById('config-action-copy').checked = false;
|
||||||
|
populateProjectAutocomplete();
|
||||||
|
const currentProject = projectsCatalog.find(p => p.uuid === (currentProjectUUID || projectUUID));
|
||||||
|
if (currentProject) {
|
||||||
|
const entry = {
|
||||||
|
code: (currentProject.code || '').trim(),
|
||||||
|
name: (currentProject.name || '').trim()
|
||||||
|
};
|
||||||
|
document.getElementById('config-action-project-input').value = formatProjectAutocompleteValue(entry);
|
||||||
|
populateVariantAutocomplete(entry.code, normalizeVariantLabel(currentProject.variant || ''));
|
||||||
|
} else {
|
||||||
|
document.getElementById('config-action-project-input').value = '';
|
||||||
|
populateVariantAutocomplete('', '');
|
||||||
|
}
|
||||||
|
syncActionModalMode();
|
||||||
|
document.getElementById('config-action-modal').classList.remove('hidden');
|
||||||
|
document.getElementById('config-action-modal').classList.add('flex');
|
||||||
|
const nameInput = document.getElementById('config-action-name');
|
||||||
|
nameInput.focus();
|
||||||
|
nameInput.select();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeConfigActionModal() {
|
||||||
|
document.getElementById('config-action-modal').classList.add('hidden');
|
||||||
|
document.getElementById('config-action-modal').classList.remove('flex');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveConfigAction() {
|
||||||
|
const notify = (message, type) => {
|
||||||
|
if (typeof showToast === 'function') {
|
||||||
|
showToast(message, type || 'success');
|
||||||
|
} else {
|
||||||
|
alert(message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const uuid = document.getElementById('config-action-uuid').value;
|
||||||
|
const currentName = document.getElementById('config-action-current-name').value;
|
||||||
|
const currentProjectUUID = document.getElementById('config-action-current-project').value || projectUUID;
|
||||||
|
const name = document.getElementById('config-action-name').value.trim();
|
||||||
|
const copy = document.getElementById('config-action-copy').checked;
|
||||||
|
const targetProject = resolveTargetProjectUUIDFromInputs();
|
||||||
|
if (targetProject.error) {
|
||||||
|
notify(targetProject.error, 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
closeCloneModal();
|
const targetProjectUUID = targetProject.uuid || currentProjectUUID;
|
||||||
loadConfigs();
|
|
||||||
|
if (!name) {
|
||||||
|
notify('Введите название', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (copy) {
|
||||||
|
const cloneResp = await fetch('/api/projects/' + targetProjectUUID + '/configs/' + uuid + '/clone', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({name: name})
|
||||||
|
});
|
||||||
|
if (!cloneResp.ok) {
|
||||||
|
notify('Не удалось скопировать квоту', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
closeConfigActionModal();
|
||||||
|
await loadConfigs();
|
||||||
|
notify('Копия создана', 'success');
|
||||||
|
if (targetProjectUUID && targetProjectUUID !== projectUUID) {
|
||||||
|
window.location.href = '/projects/' + targetProjectUUID;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let changed = false;
|
||||||
|
if (name !== currentName) {
|
||||||
|
const renameResp = await fetch('/api/configs/' + uuid + '/rename', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({name: name})
|
||||||
|
});
|
||||||
|
if (!renameResp.ok) {
|
||||||
|
notify('Не удалось переименовать квоту', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetProjectUUID !== currentProjectUUID) {
|
||||||
|
const moveResp = await fetch('/api/configs/' + uuid + '/project', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({project_uuid: targetProjectUUID})
|
||||||
|
});
|
||||||
|
if (!moveResp.ok) {
|
||||||
|
notify('Не удалось перенести квоту', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!changed) {
|
||||||
|
closeConfigActionModal();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
closeConfigActionModal();
|
||||||
|
await loadConfigs();
|
||||||
|
notify('Изменения сохранены', 'success');
|
||||||
|
if (targetProjectUUID && targetProjectUUID !== projectUUID) {
|
||||||
|
window.location.href = '/projects/' + targetProjectUUID;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function openImportModal() {
|
function openImportModal() {
|
||||||
@@ -822,62 +968,29 @@ function updateDeleteVariantButton() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function openTransferModal(configUUID) {
|
|
||||||
const select = document.getElementById('transfer-variant-select');
|
|
||||||
select.innerHTML = '';
|
|
||||||
const otherVariants = projectVariants.filter(v => v.uuid !== projectUUID);
|
|
||||||
if (otherVariants.length === 0) {
|
|
||||||
alert('Нет других вариантов для переноса');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
otherVariants.forEach(v => {
|
|
||||||
const opt = document.createElement('option');
|
|
||||||
opt.value = v.uuid;
|
|
||||||
opt.textContent = normalizeVariantLabel(v.variant);
|
|
||||||
select.appendChild(opt);
|
|
||||||
});
|
|
||||||
document.getElementById('transfer-config-uuid').value = configUUID;
|
|
||||||
document.getElementById('transfer-modal').classList.remove('hidden');
|
|
||||||
document.getElementById('transfer-modal').classList.add('flex');
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeTransferModal() {
|
|
||||||
document.getElementById('transfer-modal').classList.add('hidden');
|
|
||||||
document.getElementById('transfer-modal').classList.remove('flex');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function transferConfig() {
|
|
||||||
const configUUID = document.getElementById('transfer-config-uuid').value;
|
|
||||||
const targetProjectUUID = document.getElementById('transfer-variant-select').value;
|
|
||||||
if (!configUUID || !targetProjectUUID) return;
|
|
||||||
const resp = await fetch('/api/configs/' + configUUID + '/project', {
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: {'Content-Type': 'application/json'},
|
|
||||||
body: JSON.stringify({project_uuid: targetProjectUUID})
|
|
||||||
});
|
|
||||||
if (!resp.ok) {
|
|
||||||
alert('Не удалось перенести квоту');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
closeTransferModal();
|
|
||||||
loadConfigs();
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById('create-modal').addEventListener('click', function(e) { if (e.target === this) closeCreateModal(); });
|
document.getElementById('create-modal').addEventListener('click', function(e) { if (e.target === this) closeCreateModal(); });
|
||||||
document.getElementById('rename-modal').addEventListener('click', function(e) { if (e.target === this) closeRenameModal(); });
|
|
||||||
document.getElementById('new-variant-modal').addEventListener('click', function(e) { if (e.target === this) closeNewVariantModal(); });
|
document.getElementById('new-variant-modal').addEventListener('click', function(e) { if (e.target === this) closeNewVariantModal(); });
|
||||||
document.getElementById('clone-modal').addEventListener('click', function(e) { if (e.target === this) closeCloneModal(); });
|
document.getElementById('config-action-modal').addEventListener('click', function(e) { if (e.target === this) closeConfigActionModal(); });
|
||||||
document.getElementById('import-modal').addEventListener('click', function(e) { if (e.target === this) closeImportModal(); });
|
document.getElementById('import-modal').addEventListener('click', function(e) { if (e.target === this) closeImportModal(); });
|
||||||
document.getElementById('project-settings-modal').addEventListener('click', function(e) { if (e.target === this) closeProjectSettingsModal(); });
|
document.getElementById('project-settings-modal').addEventListener('click', function(e) { if (e.target === this) closeProjectSettingsModal(); });
|
||||||
document.getElementById('transfer-modal').addEventListener('click', function(e) { if (e.target === this) closeTransferModal(); });
|
document.getElementById('config-action-project-input').addEventListener('input', function(e) {
|
||||||
|
const code = resolveProjectCodeFromInput(e.target.value);
|
||||||
|
populateVariantAutocomplete(code, '');
|
||||||
|
});
|
||||||
|
document.getElementById('config-action-copy').addEventListener('change', function(e) {
|
||||||
|
const currentName = document.getElementById('config-action-current-name').value;
|
||||||
|
const nameInput = document.getElementById('config-action-name');
|
||||||
|
if (e.target.checked && nameInput.value.trim() === currentName.trim()) {
|
||||||
|
nameInput.value = currentName + ' (копия)';
|
||||||
|
}
|
||||||
|
syncActionModalMode();
|
||||||
|
});
|
||||||
document.addEventListener('keydown', function(e) {
|
document.addEventListener('keydown', function(e) {
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
closeCreateModal();
|
closeCreateModal();
|
||||||
closeRenameModal();
|
closeConfigActionModal();
|
||||||
closeCloneModal();
|
|
||||||
closeImportModal();
|
closeImportModal();
|
||||||
closeProjectSettingsModal();
|
closeProjectSettingsModal();
|
||||||
closeTransferModal();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user