Add configuration revisions system and project variant deletion
Features: - Configuration versioning: immutable snapshots in local_configuration_versions - Revisions UI: /configs/:uuid/revisions page to view version history - Clone from version: ability to clone configuration from specific revision - Project variant deletion: DELETE /api/projects/:uuid endpoint - Updated CLAUDE.md with new architecture details and endpoints Architecture updates: - local_configuration_versions table for immutable snapshots - Version tracking on each configuration save - Rollback capability to previous versions - Variant deletion with main variant protection Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -20,6 +20,7 @@
|
|||||||
- Local-first: чтение и запись происходят в SQLite
|
- Local-first: чтение и запись происходят в SQLite
|
||||||
- MariaDB используется как сервер синхронизации
|
- MariaDB используется как сервер синхронизации
|
||||||
- Background worker: периодический sync push+pull
|
- Background worker: периодический sync push+pull
|
||||||
|
- Система ревизий конфигураций: immutable snapshots при каждом сохранении (local_configuration_versions)
|
||||||
|
|
||||||
## Guardrails
|
## Guardrails
|
||||||
- Не возвращать в проект удалённые legacy-разделы: cron jobs, importer utility, admin pricing, alerts, stock import.
|
- Не возвращать в проект удалённые legacy-разделы: cron jobs, importer utility, admin pricing, alerts, stock import.
|
||||||
@@ -32,6 +33,7 @@
|
|||||||
- `local_components`
|
- `local_components`
|
||||||
- `local_pricelists`, `local_pricelist_items`
|
- `local_pricelists`, `local_pricelist_items`
|
||||||
- `local_configurations`
|
- `local_configurations`
|
||||||
|
- `local_configuration_versions` — immutable snapshots (ревизии) при каждом сохранении
|
||||||
- `local_projects`
|
- `local_projects`
|
||||||
- `pending_changes`
|
- `pending_changes`
|
||||||
|
|
||||||
@@ -42,14 +44,15 @@
|
|||||||
| Components | `GET /api/components`, `GET /api/components/:lot_name`, `GET /api/categories` |
|
| Components | `GET /api/components`, `GET /api/components/:lot_name`, `GET /api/categories` |
|
||||||
| Quote | `POST /api/quote/validate`, `POST /api/quote/calculate`, `POST /api/quote/price-levels` |
|
| Quote | `POST /api/quote/validate`, `POST /api/quote/calculate`, `POST /api/quote/price-levels` |
|
||||||
| Pricelists (read-only) | `GET /api/pricelists`, `GET /api/pricelists/latest`, `GET /api/pricelists/:id`, `GET /api/pricelists/:id/items`, `GET /api/pricelists/:id/lots` |
|
| Pricelists (read-only) | `GET /api/pricelists`, `GET /api/pricelists/latest`, `GET /api/pricelists/:id`, `GET /api/pricelists/:id/items`, `GET /api/pricelists/:id/lots` |
|
||||||
| Configs | CRUD + refresh/clone/reactivate/rename/project binding via `/api/configs/*` |
|
| Configs | CRUD + refresh/clone/reactivate/rename/project binding + versions/rollback via `/api/configs/*` |
|
||||||
| Projects | CRUD + nested configs via `/api/projects/*` |
|
| Projects | CRUD + nested configs + `DELETE /api/projects/:uuid` (delete variant) via `/api/projects/*` |
|
||||||
| Sync | `GET /api/sync/status`, `GET /api/sync/readiness`, `GET /api/sync/info`, `GET /api/sync/users-status`, `POST /api/sync/components`, `POST /api/sync/pricelists`, `POST /api/sync/all`, `POST /api/sync/push`, `GET /api/sync/pending`, `GET /api/sync/pending/count` |
|
| Sync | `GET /api/sync/status`, `GET /api/sync/readiness`, `GET /api/sync/info`, `GET /api/sync/users-status`, `POST /api/sync/components`, `POST /api/sync/pricelists`, `POST /api/sync/all`, `POST /api/sync/push`, `GET /api/sync/pending`, `GET /api/sync/pending/count` |
|
||||||
| Export | `POST /api/export/csv` |
|
| Export | `POST /api/export/csv` |
|
||||||
|
|
||||||
## Web Routes
|
## Web Routes
|
||||||
- `/configs`
|
- `/configs`
|
||||||
- `/configurator`
|
- `/configurator`
|
||||||
|
- `/configs/:uuid/revisions`
|
||||||
- `/projects`
|
- `/projects`
|
||||||
- `/projects/:uuid`
|
- `/projects/:uuid`
|
||||||
- `/pricelists`
|
- `/pricelists`
|
||||||
|
|||||||
+25
-2
@@ -930,6 +930,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
router.GET("/configurator", webHandler.Configurator)
|
router.GET("/configurator", webHandler.Configurator)
|
||||||
router.GET("/projects", webHandler.Projects)
|
router.GET("/projects", webHandler.Projects)
|
||||||
router.GET("/projects/:uuid", webHandler.ProjectDetail)
|
router.GET("/projects/:uuid", webHandler.ProjectDetail)
|
||||||
|
router.GET("/configs/:uuid/revisions", webHandler.ConfigRevisions)
|
||||||
router.GET("/pricelists", webHandler.Pricelists)
|
router.GET("/pricelists", webHandler.Pricelists)
|
||||||
router.GET("/pricelists/:id", webHandler.PricelistDetail)
|
router.GET("/pricelists/:id", webHandler.PricelistDetail)
|
||||||
|
|
||||||
@@ -1129,15 +1130,20 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
configs.POST("/:uuid/clone", func(c *gin.Context) {
|
configs.POST("/:uuid/clone", func(c *gin.Context) {
|
||||||
uuid := c.Param("uuid")
|
uuid := c.Param("uuid")
|
||||||
var req struct {
|
var req struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
FromVersion int `json:"from_version"`
|
||||||
}
|
}
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
config, err := configService.CloneNoAuth(uuid, req.Name, dbUsername)
|
config, err := configService.CloneNoAuthToProjectFromVersion(uuid, req.Name, dbUsername, nil, req.FromVersion)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if errors.Is(err, services.ErrConfigVersionNotFound) {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "version not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1579,6 +1585,23 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
c.JSON(http.StatusOK, gin.H{"message": "project reactivated"})
|
c.JSON(http.StatusOK, gin.H{"message": "project reactivated"})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
projects.DELETE("/:uuid", func(c *gin.Context) {
|
||||||
|
if err := projectService.DeleteVariant(c.Param("uuid"), dbUsername); err != nil {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, services.ErrCannotDeleteMainVariant):
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
case errors.Is(err, services.ErrProjectNotFound):
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||||
|
case errors.Is(err, services.ErrProjectForbidden):
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
|
||||||
|
default:
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "variant deleted"})
|
||||||
|
})
|
||||||
|
|
||||||
projects.GET("/:uuid/configs", func(c *gin.Context) {
|
projects.GET("/:uuid/configs", func(c *gin.Context) {
|
||||||
triggerPull("configs", &configsPullState, syncConfigurationsFromServer)
|
triggerPull("configs", &configsPullState, syncConfigurationsFromServer)
|
||||||
|
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ func NewWebHandler(templatesPath string, componentService *services.ComponentSer
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Load each page template with base
|
// Load each page template with base
|
||||||
simplePages := []string{"login.html", "configs.html", "projects.html", "project_detail.html", "pricelists.html", "pricelist_detail.html"}
|
simplePages := []string{"login.html", "configs.html", "projects.html", "project_detail.html", "pricelists.html", "pricelist_detail.html", "config_revisions.html"}
|
||||||
for _, page := range simplePages {
|
for _, page := range simplePages {
|
||||||
pagePath := filepath.Join(templatesPath, page)
|
pagePath := filepath.Join(templatesPath, page)
|
||||||
var tmpl *template.Template
|
var tmpl *template.Template
|
||||||
@@ -197,6 +197,13 @@ func (h *WebHandler) ProjectDetail(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *WebHandler) ConfigRevisions(c *gin.Context) {
|
||||||
|
h.render(c, "config_revisions.html", gin.H{
|
||||||
|
"ActivePage": "configs",
|
||||||
|
"ConfigUUID": c.Param("uuid"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func (h *WebHandler) Pricelists(c *gin.Context) {
|
func (h *WebHandler) Pricelists(c *gin.Context) {
|
||||||
h.render(c, "pricelists.html", gin.H{"ActivePage": "pricelists"})
|
h.render(c, "pricelists.html", gin.H{"ActivePage": "pricelists"})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,6 +91,9 @@ func LocalToConfiguration(local *LocalConfiguration) *models.Configuration {
|
|||||||
userID := local.OriginalUserID
|
userID := local.OriginalUserID
|
||||||
cfg.UserID = &userID
|
cfg.UserID = &userID
|
||||||
}
|
}
|
||||||
|
if local.CurrentVersion != nil {
|
||||||
|
cfg.CurrentVersionNo = local.CurrentVersion.VersionNo
|
||||||
|
}
|
||||||
|
|
||||||
return cfg
|
return cfg
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ type Configuration struct {
|
|||||||
OnlyInStock bool `gorm:"default:false" json:"only_in_stock"`
|
OnlyInStock bool `gorm:"default:false" json:"only_in_stock"`
|
||||||
PriceUpdatedAt *time.Time `gorm:"type:timestamp" json:"price_updated_at,omitempty"`
|
PriceUpdatedAt *time.Time `gorm:"type:timestamp" json:"price_updated_at,omitempty"`
|
||||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||||
|
CurrentVersionNo int `gorm:"-" json:"current_version_no,omitempty"`
|
||||||
|
|
||||||
User *User `gorm:"foreignKey:UserID" json:"user,omitempty"`
|
User *User `gorm:"foreignKey:UserID" json:"user,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -442,14 +442,14 @@ func (s *LocalConfigurationService) RefreshPrices(uuid string, ownerUsername str
|
|||||||
|
|
||||||
// GetByUUIDNoAuth returns configuration without ownership check
|
// GetByUUIDNoAuth returns configuration without ownership check
|
||||||
func (s *LocalConfigurationService) GetByUUIDNoAuth(uuid string) (*models.Configuration, error) {
|
func (s *LocalConfigurationService) GetByUUIDNoAuth(uuid string) (*models.Configuration, error) {
|
||||||
localCfg, err := s.localDB.GetConfigurationByUUID(uuid)
|
var localCfg localdb.LocalConfiguration
|
||||||
if err != nil {
|
if err := s.localDB.DB().Preload("CurrentVersion").Where("uuid = ?", uuid).First(&localCfg).Error; err != nil {
|
||||||
return nil, ErrConfigNotFound
|
return nil, ErrConfigNotFound
|
||||||
}
|
}
|
||||||
if !localCfg.IsActive {
|
if !localCfg.IsActive {
|
||||||
return nil, ErrConfigNotFound
|
return nil, ErrConfigNotFound
|
||||||
}
|
}
|
||||||
return localdb.LocalToConfiguration(localCfg), nil
|
return localdb.LocalToConfiguration(&localCfg), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateNoAuth updates configuration without ownership check
|
// UpdateNoAuth updates configuration without ownership check
|
||||||
@@ -571,10 +571,30 @@ func (s *LocalConfigurationService) CloneNoAuth(configUUID string, newName strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *LocalConfigurationService) CloneNoAuthToProject(configUUID string, newName string, ownerUsername string, projectUUID *string) (*models.Configuration, error) {
|
func (s *LocalConfigurationService) CloneNoAuthToProject(configUUID string, newName string, ownerUsername string, projectUUID *string) (*models.Configuration, error) {
|
||||||
|
return s.CloneNoAuthToProjectFromVersion(configUUID, newName, ownerUsername, projectUUID, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *LocalConfigurationService) CloneNoAuthToProjectFromVersion(configUUID string, newName string, ownerUsername string, projectUUID *string, fromVersion int) (*models.Configuration, error) {
|
||||||
original, err := s.GetByUUIDNoAuth(configUUID)
|
original, err := s.GetByUUIDNoAuth(configUUID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If fromVersion specified, use snapshot from that version
|
||||||
|
if fromVersion > 0 {
|
||||||
|
version, vErr := s.GetVersion(configUUID, fromVersion)
|
||||||
|
if vErr != nil {
|
||||||
|
return nil, vErr
|
||||||
|
}
|
||||||
|
snapshot, decErr := s.decodeConfigurationSnapshot(version.Data)
|
||||||
|
if decErr != nil {
|
||||||
|
return nil, fmt.Errorf("decode version snapshot for clone: %w", decErr)
|
||||||
|
}
|
||||||
|
snapshotCfg := localdb.LocalToConfiguration(snapshot)
|
||||||
|
original = snapshotCfg
|
||||||
|
original.UUID = configUUID // preserve original UUID for project resolution
|
||||||
|
}
|
||||||
|
|
||||||
resolvedProjectUUID := original.ProjectUUID
|
resolvedProjectUUID := original.ProjectUUID
|
||||||
if projectUUID != nil {
|
if projectUUID != nil {
|
||||||
resolvedProjectUUID, err = s.resolveProjectUUID(ownerUsername, projectUUID)
|
resolvedProjectUUID, err = s.resolveProjectUUID(ownerUsername, projectUUID)
|
||||||
@@ -896,6 +916,7 @@ func (s *LocalConfigurationService) createWithVersion(localCfg *localdb.LocalCon
|
|||||||
return fmt.Errorf("set current version id: %w", err)
|
return fmt.Errorf("set current version id: %w", err)
|
||||||
}
|
}
|
||||||
localCfg.CurrentVersionID = &version.ID
|
localCfg.CurrentVersionID = &version.ID
|
||||||
|
localCfg.CurrentVersion = version
|
||||||
|
|
||||||
if err := s.enqueueConfigurationPendingChangeTx(tx, localCfg, "create", version, createdBy); err != nil {
|
if err := s.enqueueConfigurationPendingChangeTx(tx, localCfg, "create", version, createdBy); err != nil {
|
||||||
return fmt.Errorf("enqueue create pending change: %w", err)
|
return fmt.Errorf("enqueue create pending change: %w", err)
|
||||||
@@ -937,6 +958,7 @@ func (s *LocalConfigurationService) saveWithVersionAndPending(localCfg *localdb.
|
|||||||
return fmt.Errorf("update current version id: %w", err)
|
return fmt.Errorf("update current version id: %w", err)
|
||||||
}
|
}
|
||||||
localCfg.CurrentVersionID = &version.ID
|
localCfg.CurrentVersionID = &version.ID
|
||||||
|
localCfg.CurrentVersion = version
|
||||||
|
|
||||||
cfg = localdb.LocalToConfiguration(localCfg)
|
cfg = localdb.LocalToConfiguration(localCfg)
|
||||||
if err := s.enqueueConfigurationPendingChangeTx(tx, localCfg, operation, version, createdBy); err != nil {
|
if err := s.enqueueConfigurationPendingChangeTx(tx, localCfg, operation, version, createdBy); err != nil {
|
||||||
|
|||||||
@@ -16,9 +16,10 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
ErrProjectNotFound = errors.New("project not found")
|
ErrProjectNotFound = errors.New("project not found")
|
||||||
ErrProjectForbidden = errors.New("access to project forbidden")
|
ErrProjectForbidden = errors.New("access to project forbidden")
|
||||||
ErrProjectCodeExists = errors.New("project code and variant already exist")
|
ErrProjectCodeExists = errors.New("project code and variant already exist")
|
||||||
|
ErrCannotDeleteMainVariant = errors.New("cannot delete main variant")
|
||||||
)
|
)
|
||||||
|
|
||||||
type ProjectService struct {
|
type ProjectService struct {
|
||||||
@@ -173,6 +174,17 @@ func (s *ProjectService) Reactivate(projectUUID, ownerUsername string) error {
|
|||||||
return s.setProjectActive(projectUUID, ownerUsername, true)
|
return s.setProjectActive(projectUUID, ownerUsername, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *ProjectService) DeleteVariant(projectUUID, ownerUsername string) error {
|
||||||
|
localProject, err := s.localDB.GetProjectByUUID(projectUUID)
|
||||||
|
if err != nil {
|
||||||
|
return ErrProjectNotFound
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(localProject.Variant) == "" {
|
||||||
|
return ErrCannotDeleteMainVariant
|
||||||
|
}
|
||||||
|
return s.setProjectActive(projectUUID, ownerUsername, false)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *ProjectService) setProjectActive(projectUUID, ownerUsername string, isActive bool) error {
|
func (s *ProjectService) setProjectActive(projectUUID, ownerUsername string, isActive bool) error {
|
||||||
return s.localDB.DB().Transaction(func(tx *gorm.DB) error {
|
return s.localDB.DB().Transaction(func(tx *gorm.DB) error {
|
||||||
var project localdb.LocalProject
|
var project localdb.LocalProject
|
||||||
@@ -263,8 +275,8 @@ func (s *ProjectService) ListConfigurations(projectUUID, ownerUsername, status s
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
localConfigs, err := s.localDB.GetConfigurations()
|
var localConfigs []localdb.LocalConfiguration
|
||||||
if err != nil {
|
if err := s.localDB.DB().Preload("CurrentVersion").Order("created_at DESC").Find(&localConfigs).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,202 @@
|
|||||||
|
{{define "title"}}Ревизии - QuoteForge{{end}}
|
||||||
|
|
||||||
|
{{define "content"}}
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<a href="/projects" class="text-gray-500 hover:text-gray-700" title="Все проекты">
|
||||||
|
<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="M3 9.75L12 3l9 6.75v9A2.25 2.25 0 0118.75 21h-13.5A2.25 2.25 0 013 18.75v-9z"></path>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 21v-6h6v6"></path>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
<div class="text-2xl font-bold flex items-center gap-2" id="revisions-breadcrumbs">
|
||||||
|
<a id="breadcrumb-code-link" href="/projects" class="text-blue-700 hover:underline">
|
||||||
|
<span id="breadcrumb-code">—</span>
|
||||||
|
</a>
|
||||||
|
<span class="text-gray-400">-</span>
|
||||||
|
<a id="breadcrumb-variant-link" href="/projects" class="text-blue-700 hover:underline">
|
||||||
|
<span id="breadcrumb-variant">main</span>
|
||||||
|
</a>
|
||||||
|
<span class="text-gray-400">-</span>
|
||||||
|
<a id="breadcrumb-config-link" href="/configurator" class="text-blue-700 hover:underline">
|
||||||
|
<span id="breadcrumb-config">—</span>
|
||||||
|
</a>
|
||||||
|
<span class="text-gray-400">-</span>
|
||||||
|
<span class="text-gray-600">Ревизии</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="revisions-list">
|
||||||
|
<div class="text-center py-8 text-gray-500">Загрузка...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const configUUID = '{{.ConfigUUID}}';
|
||||||
|
let configData = null;
|
||||||
|
let projectData = null;
|
||||||
|
|
||||||
|
function escapeHtml(text) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text || '';
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadConfigInfo() {
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/configs/' + configUUID);
|
||||||
|
if (!resp.ok) {
|
||||||
|
document.getElementById('revisions-list').innerHTML =
|
||||||
|
'<div class="bg-white rounded-lg shadow p-8 text-center text-red-600">Конфигурация не найдена</div>';
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
configData = await resp.json();
|
||||||
|
|
||||||
|
document.getElementById('breadcrumb-config').textContent = configData.name || 'Конфигурация';
|
||||||
|
document.getElementById('breadcrumb-config-link').href = '/configurator?uuid=' + configUUID;
|
||||||
|
|
||||||
|
if (configData.project_uuid) {
|
||||||
|
const projResp = await fetch('/api/projects/' + configData.project_uuid);
|
||||||
|
if (projResp.ok) {
|
||||||
|
projectData = await projResp.json();
|
||||||
|
document.getElementById('breadcrumb-code').textContent = projectData.code || '—';
|
||||||
|
const variant = (projectData.variant || '').trim();
|
||||||
|
document.getElementById('breadcrumb-variant').textContent = variant === '' ? 'main' : variant;
|
||||||
|
document.getElementById('breadcrumb-variant-link').href = '/projects/' + projectData.uuid;
|
||||||
|
|
||||||
|
// Find main variant for code link
|
||||||
|
const allResp = await fetch('/api/projects/all');
|
||||||
|
if (allResp.ok) {
|
||||||
|
const allProjects = await allResp.json();
|
||||||
|
const main = (Array.isArray(allProjects) ? allProjects : []).find(
|
||||||
|
p => p.code === projectData.code && (p.variant || '').trim() === ''
|
||||||
|
);
|
||||||
|
if (main) {
|
||||||
|
document.getElementById('breadcrumb-code-link').href = '/projects/' + main.uuid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
document.getElementById('revisions-list').innerHTML =
|
||||||
|
'<div class="bg-white rounded-lg shadow p-8 text-center text-red-600">Ошибка загрузки</div>';
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadVersions() {
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/configs/' + configUUID + '/versions?limit=200');
|
||||||
|
if (!resp.ok) {
|
||||||
|
document.getElementById('revisions-list').innerHTML =
|
||||||
|
'<div class="bg-white rounded-lg shadow p-8 text-center text-red-600">Ошибка загрузки ревизий</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await resp.json();
|
||||||
|
const versions = data.versions || [];
|
||||||
|
renderVersions(versions);
|
||||||
|
} catch (e) {
|
||||||
|
document.getElementById('revisions-list').innerHTML =
|
||||||
|
'<div class="bg-white rounded-lg shadow p-8 text-center text-red-600">Ошибка загрузки</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderVersions(versions) {
|
||||||
|
if (versions.length === 0) {
|
||||||
|
document.getElementById('revisions-list').innerHTML =
|
||||||
|
'<div class="bg-white rounded-lg shadow p-8 text-center text-gray-500">Нет ревизий</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = '<div class="bg-white rounded-lg shadow overflow-hidden"><table class="w-full">';
|
||||||
|
html += '<thead class="bg-gray-50"><tr>';
|
||||||
|
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 += '</tr></thead><tbody class="divide-y">';
|
||||||
|
|
||||||
|
versions.forEach((v, idx) => {
|
||||||
|
const date = new Date(v.created_at).toLocaleString('ru-RU');
|
||||||
|
const author = v.created_by || '—';
|
||||||
|
const note = v.change_note || '—';
|
||||||
|
const isCurrent = idx === 0;
|
||||||
|
|
||||||
|
html += '<tr class="hover:bg-gray-50' + (isCurrent ? ' bg-blue-50' : '') + '">';
|
||||||
|
html += '<td class="px-4 py-3 text-sm font-medium">';
|
||||||
|
html += 'v' + v.version_no;
|
||||||
|
if (isCurrent) html += ' <span class="text-xs text-blue-600 font-normal">(текущая)</span>';
|
||||||
|
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(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-right space-x-2">';
|
||||||
|
|
||||||
|
// Open in configurator (readonly view)
|
||||||
|
html += '<a href="/configurator?uuid=' + configUUID + '&version=' + v.version_no + '" class="text-blue-600 hover:text-blue-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="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path></svg></a>';
|
||||||
|
|
||||||
|
// Clone from this version
|
||||||
|
html += '<button onclick="cloneFromVersion(' + v.version_no + ')" 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>';
|
||||||
|
|
||||||
|
// Rollback (not for current version)
|
||||||
|
if (!isCurrent) {
|
||||||
|
html += '<button onclick="rollbackToVersion(' + v.version_no + ')" class="text-orange-600 hover:text-orange-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="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6"></path></svg></button>';
|
||||||
|
}
|
||||||
|
|
||||||
|
html += '</td></tr>';
|
||||||
|
});
|
||||||
|
|
||||||
|
html += '</tbody></table></div>';
|
||||||
|
document.getElementById('revisions-list').innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cloneFromVersion(versionNo) {
|
||||||
|
const name = prompt('Название копии:', (configData ? configData.name : '') + ' (v' + versionNo + ')');
|
||||||
|
if (!name) return;
|
||||||
|
const resp = await fetch('/api/configs/' + configUUID + '/clone', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({name: name, from_version: versionNo})
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
const data = await resp.json().catch(() => ({}));
|
||||||
|
alert(data.error || 'Не удалось скопировать');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const created = await resp.json();
|
||||||
|
showToast('Копия создана', 'success');
|
||||||
|
if (created && created.uuid) {
|
||||||
|
window.location.href = '/configurator?uuid=' + created.uuid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rollbackToVersion(versionNo) {
|
||||||
|
if (!confirm('Восстановить конфигурацию до ревизии v' + versionNo + '?')) return;
|
||||||
|
const resp = await fetch('/api/configs/' + configUUID + '/rollback', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({target_version: versionNo})
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
const data = await resp.json().catch(() => ({}));
|
||||||
|
alert(data.error || 'Не удалось восстановить');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showToast('Ревизия восстановлена', 'success');
|
||||||
|
loadVersions();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', async function() {
|
||||||
|
const ok = await loadConfigInfo();
|
||||||
|
if (!ok) return;
|
||||||
|
await loadVersions();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{template "base" .}}
|
||||||
@@ -20,7 +20,9 @@
|
|||||||
<span id="breadcrumb-project-variant">main</span>
|
<span id="breadcrumb-project-variant">main</span>
|
||||||
</a>
|
</a>
|
||||||
<span class="text-gray-400">-</span>
|
<span class="text-gray-400">-</span>
|
||||||
<span id="breadcrumb-config-name">Конфигуратор</span>
|
<a id="breadcrumb-config-name-link" href="#" class="text-blue-700 hover:underline">
|
||||||
|
<span id="breadcrumb-config-name">Конфигуратор</span>
|
||||||
|
</a>
|
||||||
<span class="text-gray-400">-</span>
|
<span class="text-gray-400">-</span>
|
||||||
<span id="breadcrumb-config-version">v1</span>
|
<span id="breadcrumb-config-version">v1</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -338,6 +340,7 @@ let ASSIGNED_CATEGORIES = Object.values(TAB_CONFIG)
|
|||||||
// State
|
// State
|
||||||
let configUUID = '{{.ConfigUUID}}';
|
let configUUID = '{{.ConfigUUID}}';
|
||||||
let configName = '';
|
let configName = '';
|
||||||
|
let currentVersionNo = 1;
|
||||||
let projectUUID = '';
|
let projectUUID = '';
|
||||||
let projectName = '';
|
let projectName = '';
|
||||||
let projectCode = '';
|
let projectCode = '';
|
||||||
@@ -399,7 +402,11 @@ function updateConfigBreadcrumbs() {
|
|||||||
codeEl.textContent = code;
|
codeEl.textContent = code;
|
||||||
variantEl.textContent = variant;
|
variantEl.textContent = variant;
|
||||||
configEl.textContent = configName || 'Конфигурация';
|
configEl.textContent = configName || 'Конфигурация';
|
||||||
versionEl.textContent = 'v1';
|
versionEl.textContent = 'v' + (currentVersionNo || 1);
|
||||||
|
const configNameLinkEl = document.getElementById('breadcrumb-config-name-link');
|
||||||
|
if (configNameLinkEl && configUUID) {
|
||||||
|
configNameLinkEl.href = '/configs/' + configUUID + '/revisions';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
let currentTab = 'base';
|
let currentTab = 'base';
|
||||||
let allComponents = [];
|
let allComponents = [];
|
||||||
@@ -688,6 +695,7 @@ document.addEventListener('DOMContentLoaded', async function() {
|
|||||||
|
|
||||||
const config = await resp.json();
|
const config = await resp.json();
|
||||||
configName = config.name;
|
configName = config.name;
|
||||||
|
currentVersionNo = config.current_version_no || 1;
|
||||||
projectUUID = config.project_uuid || '';
|
projectUUID = config.project_uuid || '';
|
||||||
await loadProjectIndex();
|
await loadProjectIndex();
|
||||||
updateConfigBreadcrumbs();
|
updateConfigBreadcrumbs();
|
||||||
@@ -1937,6 +1945,13 @@ async function saveConfig(showNotification = true) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const saved = await resp.json();
|
||||||
|
if (saved && saved.current_version_no) {
|
||||||
|
currentVersionNo = saved.current_version_no;
|
||||||
|
const versionEl = document.getElementById('breadcrumb-config-version');
|
||||||
|
if (versionEl) versionEl.textContent = 'v' + currentVersionNo;
|
||||||
|
}
|
||||||
|
|
||||||
if (showNotification) {
|
if (showNotification) {
|
||||||
showToast('Сохранено', 'success');
|
showToast('Сохранено', 'success');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="action-buttons" class="mt-4 grid grid-cols-1 sm:grid-cols-4 gap-3">
|
<div id="action-buttons" class="mt-4 grid grid-cols-1 sm:grid-cols-5 gap-3">
|
||||||
<button onclick="openNewVariantModal()" class="py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 font-medium">
|
<button onclick="openNewVariantModal()" class="py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 font-medium">
|
||||||
+ Новый вариант
|
+ Новый вариант
|
||||||
</button>
|
</button>
|
||||||
@@ -43,6 +43,9 @@
|
|||||||
<button onclick="openProjectSettingsModal()" class="py-2 bg-gray-700 text-white rounded-lg hover:bg-gray-800 font-medium">
|
<button onclick="openProjectSettingsModal()" class="py-2 bg-gray-700 text-white rounded-lg hover:bg-gray-800 font-medium">
|
||||||
Параметры
|
Параметры
|
||||||
</button>
|
</button>
|
||||||
|
<button id="delete-variant-btn" onclick="deleteVariant()" class="py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 font-medium hidden">
|
||||||
|
Удалить вариант
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-2">
|
<div class="mt-2">
|
||||||
<a id="tracker-link" href="https://tracker.yandex.ru/OPS-1933" target="_blank" rel="noopener noreferrer" class="text-sm text-blue-600 hover:text-blue-800 hover:underline">
|
<a id="tracker-link" href="https://tracker.yandex.ru/OPS-1933" target="_blank" rel="noopener noreferrer" class="text-sm text-blue-600 hover:text-blue-800 hover:underline">
|
||||||
@@ -196,6 +199,24 @@
|
|||||||
</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';
|
||||||
@@ -241,7 +262,7 @@ async function loadVariantsForCode(code) {
|
|||||||
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 || []);
|
||||||
projectVariants = allProjects
|
projectVariants = allProjects
|
||||||
.filter(p => (p.code || '').trim() === code)
|
.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()}));
|
||||||
projectVariants.sort((a, b) => normalizeVariantLabel(a.variant).localeCompare(normalizeVariantLabel(b.variant)));
|
projectVariants.sort((a, b) => normalizeVariantLabel(a.variant).localeCompare(normalizeVariantLabel(b.variant)));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -341,6 +362,7 @@ function renderConfigs(configs) {
|
|||||||
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Цена (за 1 шт)</th>';
|
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Цена (за 1 шт)</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 += '<th class="px-4 py-3 text-center 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">';
|
||||||
|
|
||||||
@@ -363,11 +385,17 @@ function renderConfigs(configs) {
|
|||||||
html += '<td class="px-4 py-3 text-sm text-gray-500">$' + unitPrice.toLocaleString('en-US', {minimumFractionDigits: 2}) + '</td>';
|
html += '<td class="px-4 py-3 text-sm text-gray-500">$' + unitPrice.toLocaleString('en-US', {minimumFractionDigits: 2}) + '</td>';
|
||||||
html += '<td class="px-4 py-3 text-sm text-gray-500">' + serverCount + '</td>';
|
html += '<td class="px-4 py-3 text-sm text-gray-500">' + serverCount + '</td>';
|
||||||
html += '<td class="px-4 py-3 text-sm text-right">$' + total.toLocaleString('en-US', {minimumFractionDigits: 2}) + '</td>';
|
html += '<td class="px-4 py-3 text-sm text-right">$' + total.toLocaleString('en-US', {minimumFractionDigits: 2}) + '</td>';
|
||||||
|
const versionNo = c.current_version_no || 1;
|
||||||
|
html += '<td class="px-4 py-3 text-sm text-center text-gray-500">v' + versionNo + '</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">';
|
||||||
if (configStatusMode === 'archived') {
|
if (configStatusMode === 'archived') {
|
||||||
html += '<button onclick="reactivateConfig(\'' + c.uuid + '\')" class="text-emerald-600 hover:text-emerald-800" title="Восстановить">';
|
html += '<button onclick="reactivateConfig(\'' + c.uuid + '\')" class="text-emerald-600 hover:text-emerald-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="M5 13l4 4L19 7"></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="M5 13l4 4L19 7"></path></svg></button>';
|
||||||
} else {
|
} else {
|
||||||
|
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 += '<button onclick="openTransferModal(\'' + c.uuid + '\')" 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 += '<button onclick="openCloneModal(\'' + c.uuid + '\', \'' + escapeHtml(c.name).replace(/'/g, "\\'") + '\')" class="text-green-600 hover:text-green-800" title="Копировать">';
|
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 += '<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 += '<button onclick="openRenameModal(\'' + c.uuid + '\', \'' + escapeHtml(c.name).replace(/'/g, "\\'") + '\')" class="text-blue-600 hover:text-blue-800" title="Переименовать">';
|
||||||
@@ -385,6 +413,7 @@ function renderConfigs(configs) {
|
|||||||
html += '<td class="px-4 py-3 text-sm text-gray-700">' + configs.length + '</td>';
|
html += '<td class="px-4 py-3 text-sm text-gray-700">' + configs.length + '</td>';
|
||||||
html += '<td class="px-4 py-3 text-sm text-right font-semibold text-gray-900">$' + totalSum.toLocaleString('en-US', {minimumFractionDigits: 2}) + '</td>';
|
html += '<td class="px-4 py-3 text-sm text-right font-semibold text-gray-900">$' + totalSum.toLocaleString('en-US', {minimumFractionDigits: 2}) + '</td>';
|
||||||
html += '<td class="px-4 py-3"></td>';
|
html += '<td class="px-4 py-3"></td>';
|
||||||
|
html += '<td class="px-4 py-3"></td>';
|
||||||
html += '</tr>';
|
html += '</tr>';
|
||||||
html += '</tfoot>';
|
html += '</tfoot>';
|
||||||
html += '</table></div>';
|
html += '</table></div>';
|
||||||
@@ -757,12 +786,83 @@ function wildcardMatch(value, pattern) {
|
|||||||
return regex.test(value);
|
return regex.test(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function deleteVariant() {
|
||||||
|
if (!project) return;
|
||||||
|
const variantLabel = normalizeVariantLabel(project.variant);
|
||||||
|
if (!confirm('Удалить вариант «' + variantLabel + '»? Все квоты будут архивированы.')) return;
|
||||||
|
const resp = await fetch('/api/projects/' + projectUUID, {method: 'DELETE'});
|
||||||
|
if (!resp.ok) {
|
||||||
|
const data = await resp.json().catch(() => ({}));
|
||||||
|
alert(data.error || 'Не удалось удалить вариант');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Redirect to main variant or projects list
|
||||||
|
const mainVariant = projectVariants.find(v => normalizeVariantLabel(v.variant) === 'main');
|
||||||
|
if (mainVariant && mainVariant.uuid !== projectUUID) {
|
||||||
|
window.location.href = '/projects/' + mainVariant.uuid;
|
||||||
|
} else {
|
||||||
|
window.location.href = '/projects';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateDeleteVariantButton() {
|
||||||
|
const btn = document.getElementById('delete-variant-btn');
|
||||||
|
if (!btn || !project) return;
|
||||||
|
if ((project.variant || '').trim() !== '') {
|
||||||
|
btn.classList.remove('hidden');
|
||||||
|
} else {
|
||||||
|
btn.classList.add('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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('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('clone-modal').addEventListener('click', function(e) { if (e.target === this) closeCloneModal(); });
|
||||||
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.addEventListener('keydown', function(e) {
|
document.addEventListener('keydown', function(e) {
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
closeCreateModal();
|
closeCreateModal();
|
||||||
@@ -770,6 +870,7 @@ document.addEventListener('keydown', function(e) {
|
|||||||
closeCloneModal();
|
closeCloneModal();
|
||||||
closeImportModal();
|
closeImportModal();
|
||||||
closeProjectSettingsModal();
|
closeProjectSettingsModal();
|
||||||
|
closeTransferModal();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -777,6 +878,7 @@ document.addEventListener('DOMContentLoaded', async function() {
|
|||||||
applyStatusModeUI();
|
applyStatusModeUI();
|
||||||
const ok = await loadProject();
|
const ok = await loadProject();
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
|
updateDeleteVariantButton();
|
||||||
await loadConfigs();
|
await loadConfigs();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
Reference in New Issue
Block a user