Add project variants and UI updates
This commit is contained in:
@@ -163,7 +163,7 @@ func buildPlan(db *gorm.DB, fallbackOwner string) ([]migrationAction, map[string
|
|||||||
}
|
}
|
||||||
for i := range projects {
|
for i := range projects {
|
||||||
p := projects[i]
|
p := projects[i]
|
||||||
existingProjects[projectKey(p.OwnerUsername, p.Name)] = &p
|
existingProjects[projectKey(p.OwnerUsername, derefString(p.Name))] = &p
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -258,7 +258,8 @@ func executePlan(db *gorm.DB, actions []migrationAction, existingProjects map[st
|
|||||||
project = &models.Project{
|
project = &models.Project{
|
||||||
UUID: uuid.NewString(),
|
UUID: uuid.NewString(),
|
||||||
OwnerUsername: action.OwnerUsername,
|
OwnerUsername: action.OwnerUsername,
|
||||||
Name: action.TargetProjectName,
|
Code: action.TargetProjectName,
|
||||||
|
Name: ptrString(action.TargetProjectName),
|
||||||
IsActive: true,
|
IsActive: true,
|
||||||
IsSystem: false,
|
IsSystem: false,
|
||||||
}
|
}
|
||||||
@@ -268,7 +269,7 @@ func executePlan(db *gorm.DB, actions []migrationAction, existingProjects map[st
|
|||||||
projectCache[key] = project
|
projectCache[key] = project
|
||||||
} else if !project.IsActive {
|
} else if !project.IsActive {
|
||||||
if err := tx.Model(&models.Project{}).Where("uuid = ?", project.UUID).Update("is_active", true).Error; err != nil {
|
if err := tx.Model(&models.Project{}).Where("uuid = ?", project.UUID).Update("is_active", true).Error; err != nil {
|
||||||
return fmt.Errorf("reactivate project %s (%s): %w", project.Name, project.UUID, err)
|
return fmt.Errorf("reactivate project %s (%s): %w", derefString(project.Name), project.UUID, err)
|
||||||
}
|
}
|
||||||
project.IsActive = true
|
project.IsActive = true
|
||||||
}
|
}
|
||||||
@@ -294,3 +295,14 @@ func setKeys(set map[string]struct{}) []string {
|
|||||||
func projectKey(owner, name string) string {
|
func projectKey(owner, name string) string {
|
||||||
return owner + "||" + name
|
return owner + "||" + name
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func derefString(value *string) string {
|
||||||
|
if value == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return *value
|
||||||
|
}
|
||||||
|
|
||||||
|
func ptrString(value string) *string {
|
||||||
|
return &value
|
||||||
|
}
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ const onDemandPullCooldown = 30 * time.Second
|
|||||||
func main() {
|
func main() {
|
||||||
configPath := flag.String("config", "", "path to config file (default: user state dir or QFS_CONFIG_PATH)")
|
configPath := flag.String("config", "", "path to config file (default: user state dir or QFS_CONFIG_PATH)")
|
||||||
localDBPath := flag.String("localdb", "", "path to local SQLite database (default: user state dir or QFS_DB_PATH)")
|
localDBPath := flag.String("localdb", "", "path to local SQLite database (default: user state dir or QFS_DB_PATH)")
|
||||||
|
resetLocalDB := flag.Bool("reset-localdb", false, "reset local SQLite data on startup (keeps connection settings)")
|
||||||
migrate := flag.Bool("migrate", false, "run database migrations")
|
migrate := flag.Bool("migrate", false, "run database migrations")
|
||||||
version := flag.Bool("version", false, "show version information")
|
version := flag.Bool("version", false, "show version information")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
@@ -100,6 +101,13 @@ func main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if shouldResetLocalDB(*resetLocalDB) {
|
||||||
|
if err := localdb.ResetData(resolvedLocalDBPath); err != nil {
|
||||||
|
slog.Error("failed to reset local database", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize local SQLite database (always used)
|
// Initialize local SQLite database (always used)
|
||||||
local, err := localdb.New(resolvedLocalDBPath)
|
local, err := localdb.New(resolvedLocalDBPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -295,6 +303,31 @@ func main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func shouldResetLocalDB(flagValue bool) bool {
|
||||||
|
if flagValue {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
value := strings.TrimSpace(os.Getenv("QFS_RESET_LOCAL_DB"))
|
||||||
|
if value == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
switch strings.ToLower(value) {
|
||||||
|
case "1", "true", "yes", "y":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func derefString(value *string) string {
|
||||||
|
if value == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return *value
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
func setConfigDefaults(cfg *config.Config) {
|
func setConfigDefaults(cfg *config.Config) {
|
||||||
if cfg.Server.Host == "" {
|
if cfg.Server.Host == "" {
|
||||||
cfg.Server.Host = "127.0.0.1"
|
cfg.Server.Host = "127.0.0.1"
|
||||||
@@ -1306,7 +1339,10 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
if status == "archived" && p.IsActive {
|
if status == "archived" && p.IsActive {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if search != "" && !strings.Contains(strings.ToLower(p.Name), search) {
|
if search != "" &&
|
||||||
|
!strings.Contains(strings.ToLower(derefString(p.Name)), search) &&
|
||||||
|
!strings.Contains(strings.ToLower(p.Code), search) &&
|
||||||
|
!strings.Contains(strings.ToLower(p.Variant), search) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if author != "" && !strings.Contains(strings.ToLower(strings.TrimSpace(p.OwnerUsername)), author) {
|
if author != "" && !strings.Contains(strings.ToLower(strings.TrimSpace(p.OwnerUsername)), author) {
|
||||||
@@ -1319,8 +1355,8 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
left := filtered[i]
|
left := filtered[i]
|
||||||
right := filtered[j]
|
right := filtered[j]
|
||||||
if sortField == "name" {
|
if sortField == "name" {
|
||||||
leftName := strings.ToLower(strings.TrimSpace(left.Name))
|
leftName := strings.ToLower(strings.TrimSpace(derefString(left.Name)))
|
||||||
rightName := strings.ToLower(strings.TrimSpace(right.Name))
|
rightName := strings.ToLower(strings.TrimSpace(derefString(right.Name)))
|
||||||
if leftName == rightName {
|
if leftName == rightName {
|
||||||
if sortDir == "asc" {
|
if sortDir == "asc" {
|
||||||
return left.CreatedAt.Before(right.CreatedAt)
|
return left.CreatedAt.Before(right.CreatedAt)
|
||||||
@@ -1333,8 +1369,8 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
return leftName > rightName
|
return leftName > rightName
|
||||||
}
|
}
|
||||||
if left.CreatedAt.Equal(right.CreatedAt) {
|
if left.CreatedAt.Equal(right.CreatedAt) {
|
||||||
leftName := strings.ToLower(strings.TrimSpace(left.Name))
|
leftName := strings.ToLower(strings.TrimSpace(derefString(left.Name)))
|
||||||
rightName := strings.ToLower(strings.TrimSpace(right.Name))
|
rightName := strings.ToLower(strings.TrimSpace(derefString(right.Name)))
|
||||||
if sortDir == "asc" {
|
if sortDir == "asc" {
|
||||||
return leftName < rightName
|
return leftName < rightName
|
||||||
}
|
}
|
||||||
@@ -1393,6 +1429,8 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
"id": p.ID,
|
"id": p.ID,
|
||||||
"uuid": p.UUID,
|
"uuid": p.UUID,
|
||||||
"owner_username": p.OwnerUsername,
|
"owner_username": p.OwnerUsername,
|
||||||
|
"code": p.Code,
|
||||||
|
"variant": p.Variant,
|
||||||
"name": p.Name,
|
"name": p.Name,
|
||||||
"tracker_url": p.TrackerURL,
|
"tracker_url": p.TrackerURL,
|
||||||
"is_active": p.IsActive,
|
"is_active": p.IsActive,
|
||||||
@@ -1429,6 +1467,8 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
// Return simplified list of all projects (UUID + Name only)
|
// Return simplified list of all projects (UUID + Name only)
|
||||||
type ProjectSimple struct {
|
type ProjectSimple struct {
|
||||||
UUID string `json:"uuid"`
|
UUID string `json:"uuid"`
|
||||||
|
Code string `json:"code"`
|
||||||
|
Variant string `json:"variant"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
IsActive bool `json:"is_active"`
|
IsActive bool `json:"is_active"`
|
||||||
}
|
}
|
||||||
@@ -1437,7 +1477,9 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
for _, p := range allProjects {
|
for _, p := range allProjects {
|
||||||
simplified = append(simplified, ProjectSimple{
|
simplified = append(simplified, ProjectSimple{
|
||||||
UUID: p.UUID,
|
UUID: p.UUID,
|
||||||
Name: p.Name,
|
Code: p.Code,
|
||||||
|
Variant: p.Variant,
|
||||||
|
Name: derefString(p.Name),
|
||||||
IsActive: p.IsActive,
|
IsActive: p.IsActive,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -1451,14 +1493,14 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(req.Name) == "" {
|
if strings.TrimSpace(req.Code) == "" {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "project name is required"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "project code is required"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
project, err := projectService.Create(dbUsername, &req)
|
project, err := projectService.Create(dbUsername, &req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, services.ErrProjectNameExists):
|
case errors.Is(err, services.ErrProjectCodeExists):
|
||||||
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
||||||
default:
|
default:
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
@@ -1490,14 +1532,10 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(req.Name) == "" {
|
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "project name is required"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
project, err := projectService.Update(c.Param("uuid"), dbUsername, &req)
|
project, err := projectService.Update(c.Param("uuid"), dbUsername, &req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, services.ErrProjectNameExists):
|
case errors.Is(err, services.ErrProjectCodeExists):
|
||||||
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
||||||
case errors.Is(err, services.ErrProjectNotFound):
|
case errors.Is(err, services.ErrProjectNotFound):
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||||
|
|||||||
@@ -149,7 +149,7 @@ func TestProjectArchiveHidesConfigsAndCloneIntoProject(t *testing.T) {
|
|||||||
t.Fatalf("setup router: %v", err)
|
t.Fatalf("setup router: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
createProjectReq := httptest.NewRequest(http.MethodPost, "/api/projects", bytes.NewReader([]byte(`{"name":"P1"}`)))
|
createProjectReq := httptest.NewRequest(http.MethodPost, "/api/projects", bytes.NewReader([]byte(`{"name":"P1","code":"P1"}`)))
|
||||||
createProjectReq.Header.Set("Content-Type", "application/json")
|
createProjectReq.Header.Set("Content-Type", "application/json")
|
||||||
createProjectRec := httptest.NewRecorder()
|
createProjectRec := httptest.NewRecorder()
|
||||||
router.ServeHTTP(createProjectRec, createProjectReq)
|
router.ServeHTTP(createProjectRec, createProjectReq)
|
||||||
@@ -243,7 +243,7 @@ func TestConfigMoveToProjectEndpoint(t *testing.T) {
|
|||||||
t.Fatalf("setup router: %v", err)
|
t.Fatalf("setup router: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
createProjectReq := httptest.NewRequest(http.MethodPost, "/api/projects", bytes.NewReader([]byte(`{"name":"Move Project"}`)))
|
createProjectReq := httptest.NewRequest(http.MethodPost, "/api/projects", bytes.NewReader([]byte(`{"name":"Move Project","code":"MOVE"}`)))
|
||||||
createProjectReq.Header.Set("Content-Type", "application/json")
|
createProjectReq.Header.Set("Content-Type", "application/json")
|
||||||
createProjectRec := httptest.NewRecorder()
|
createProjectRec := httptest.NewRecorder()
|
||||||
router.ServeHTTP(createProjectRec, createProjectReq)
|
router.ServeHTTP(createProjectRec, createProjectReq)
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ func (h *ExportHandler) ExportCSV(c *gin.Context) {
|
|||||||
// Try to load project name from database
|
// Try to load project name from database
|
||||||
username := middleware.GetUsername(c)
|
username := middleware.GetUsername(c)
|
||||||
if project, err := h.projectService.GetByUUID(req.ProjectUUID, username); err == nil && project != nil {
|
if project, err := h.projectService.GetByUUID(req.ProjectUUID, username); err == nil && project != nil {
|
||||||
projectName = project.Name
|
projectName = derefString(project.Name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if projectName == "" {
|
if projectName == "" {
|
||||||
@@ -90,6 +90,13 @@ func (h *ExportHandler) ExportCSV(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func derefString(value *string) string {
|
||||||
|
if value == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return *value
|
||||||
|
}
|
||||||
|
|
||||||
func (h *ExportHandler) buildExportData(req *ExportRequest) *services.ExportData {
|
func (h *ExportHandler) buildExportData(req *ExportRequest) *services.ExportData {
|
||||||
items := make([]services.ExportItem, len(req.Items))
|
items := make([]services.ExportItem, len(req.Items))
|
||||||
var total float64
|
var total float64
|
||||||
@@ -171,7 +178,7 @@ func (h *ExportHandler) ExportConfigCSV(c *gin.Context) {
|
|||||||
projectName := config.Name // fallback: use config name if no project
|
projectName := config.Name // fallback: use config name if no project
|
||||||
if config.ProjectUUID != nil && *config.ProjectUUID != "" {
|
if config.ProjectUUID != nil && *config.ProjectUUID != "" {
|
||||||
if project, err := h.projectService.GetByUUID(*config.ProjectUUID, username); err == nil && project != nil {
|
if project, err := h.projectService.GetByUUID(*config.ProjectUUID, username); err == nil && project != nil {
|
||||||
projectName = project.Name
|
projectName = derefString(project.Name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -106,6 +106,8 @@ func ProjectToLocal(project *models.Project) *LocalProject {
|
|||||||
local := &LocalProject{
|
local := &LocalProject{
|
||||||
UUID: project.UUID,
|
UUID: project.UUID,
|
||||||
OwnerUsername: project.OwnerUsername,
|
OwnerUsername: project.OwnerUsername,
|
||||||
|
Code: project.Code,
|
||||||
|
Variant: project.Variant,
|
||||||
Name: project.Name,
|
Name: project.Name,
|
||||||
TrackerURL: project.TrackerURL,
|
TrackerURL: project.TrackerURL,
|
||||||
IsActive: project.IsActive,
|
IsActive: project.IsActive,
|
||||||
@@ -125,6 +127,8 @@ func LocalToProject(local *LocalProject) *models.Project {
|
|||||||
project := &models.Project{
|
project := &models.Project{
|
||||||
UUID: local.UUID,
|
UUID: local.UUID,
|
||||||
OwnerUsername: local.OwnerUsername,
|
OwnerUsername: local.OwnerUsername,
|
||||||
|
Code: local.Code,
|
||||||
|
Variant: local.Variant,
|
||||||
Name: local.Name,
|
Name: local.Name,
|
||||||
TrackerURL: local.TrackerURL,
|
TrackerURL: local.TrackerURL,
|
||||||
IsActive: local.IsActive,
|
IsActive: local.IsActive,
|
||||||
|
|||||||
@@ -42,6 +42,49 @@ type LocalDB struct {
|
|||||||
path string
|
path string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ResetData clears local data tables while keeping connection settings.
|
||||||
|
// It does not drop schema or connection_settings.
|
||||||
|
func ResetData(dbPath string) error {
|
||||||
|
if strings.TrimSpace(dbPath) == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(dbPath); err != nil {
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("stat local db: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{
|
||||||
|
Logger: logger.Default.LogMode(logger.Silent),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("opening sqlite database: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Order does not matter because we use DELETEs without FK constraints in SQLite.
|
||||||
|
tables := []string{
|
||||||
|
"local_projects",
|
||||||
|
"local_configurations",
|
||||||
|
"local_configuration_versions",
|
||||||
|
"local_pricelists",
|
||||||
|
"local_pricelist_items",
|
||||||
|
"local_components",
|
||||||
|
"local_remote_migrations_applied",
|
||||||
|
"local_sync_guard_state",
|
||||||
|
"pending_changes",
|
||||||
|
"app_settings",
|
||||||
|
}
|
||||||
|
for _, table := range tables {
|
||||||
|
if err := db.Exec("DELETE FROM " + table).Error; err != nil {
|
||||||
|
return fmt.Errorf("clear %s: %w", table, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("local database data reset", "path", dbPath)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// New creates a new LocalDB instance
|
// New creates a new LocalDB instance
|
||||||
func New(dbPath string) (*LocalDB, error) {
|
func New(dbPath string) (*LocalDB, error) {
|
||||||
// Ensure directory exists
|
// Ensure directory exists
|
||||||
@@ -65,10 +108,31 @@ func New(dbPath string) (*LocalDB, error) {
|
|||||||
return nil, fmt.Errorf("opening sqlite database: %w", err)
|
return nil, fmt.Errorf("opening sqlite database: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := ensureLocalProjectsTable(db); err != nil {
|
||||||
|
return nil, fmt.Errorf("ensure local_projects table: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preflight: ensure local_projects has non-null UUIDs before AutoMigrate rebuilds tables.
|
||||||
|
if db.Migrator().HasTable(&LocalProject{}) {
|
||||||
|
if !db.Migrator().HasColumn(&LocalProject{}, "uuid") {
|
||||||
|
if err := db.Exec(`ALTER TABLE local_projects ADD COLUMN uuid TEXT`).Error; err != nil {
|
||||||
|
return nil, fmt.Errorf("adding local_projects.uuid: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var ids []uint
|
||||||
|
if err := db.Raw(`SELECT id FROM local_projects WHERE uuid IS NULL OR uuid = ''`).Scan(&ids).Error; err != nil {
|
||||||
|
return nil, fmt.Errorf("finding local_projects without uuid: %w", err)
|
||||||
|
}
|
||||||
|
for _, id := range ids {
|
||||||
|
if err := db.Exec(`UPDATE local_projects SET uuid = ? WHERE id = ?`, uuidpkg.New().String(), id).Error; err != nil {
|
||||||
|
return nil, fmt.Errorf("backfilling local_projects.uuid: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Auto-migrate all local tables
|
// Auto-migrate all local tables
|
||||||
if err := db.AutoMigrate(
|
if err := db.AutoMigrate(
|
||||||
&ConnectionSettings{},
|
&ConnectionSettings{},
|
||||||
&LocalProject{},
|
|
||||||
&LocalConfiguration{},
|
&LocalConfiguration{},
|
||||||
&LocalConfigurationVersion{},
|
&LocalConfigurationVersion{},
|
||||||
&LocalPricelist{},
|
&LocalPricelist{},
|
||||||
@@ -93,6 +157,38 @@ func New(dbPath string) (*LocalDB, error) {
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ensureLocalProjectsTable(db *gorm.DB) error {
|
||||||
|
if db.Migrator().HasTable(&LocalProject{}) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.Exec(`
|
||||||
|
CREATE TABLE local_projects (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
uuid TEXT NOT NULL UNIQUE,
|
||||||
|
server_id INTEGER NULL,
|
||||||
|
owner_username TEXT NOT NULL,
|
||||||
|
code TEXT NOT NULL,
|
||||||
|
variant TEXT NOT NULL DEFAULT '',
|
||||||
|
name TEXT NULL,
|
||||||
|
tracker_url TEXT NULL,
|
||||||
|
is_active INTEGER NOT NULL DEFAULT 1,
|
||||||
|
is_system INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at DATETIME,
|
||||||
|
updated_at DATETIME,
|
||||||
|
synced_at DATETIME NULL,
|
||||||
|
sync_status TEXT DEFAULT 'local'
|
||||||
|
)`).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = db.Exec(`CREATE INDEX IF NOT EXISTS idx_local_projects_owner_username ON local_projects(owner_username)`).Error
|
||||||
|
_ = db.Exec(`CREATE INDEX IF NOT EXISTS idx_local_projects_is_active ON local_projects(is_active)`).Error
|
||||||
|
_ = db.Exec(`CREATE INDEX IF NOT EXISTS idx_local_projects_is_system ON local_projects(is_system)`).Error
|
||||||
|
_ = db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_local_projects_code_variant ON local_projects(code, variant)`).Error
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// HasSettings returns true if connection settings exist
|
// HasSettings returns true if connection settings exist
|
||||||
func (l *LocalDB) HasSettings() bool {
|
func (l *LocalDB) HasSettings() bool {
|
||||||
var count int64
|
var count int64
|
||||||
@@ -267,7 +363,8 @@ func (l *LocalDB) EnsureDefaultProject(ownerUsername string) (*LocalProject, err
|
|||||||
project = &LocalProject{
|
project = &LocalProject{
|
||||||
UUID: uuidpkg.NewString(),
|
UUID: uuidpkg.NewString(),
|
||||||
OwnerUsername: "",
|
OwnerUsername: "",
|
||||||
Name: "Без проекта",
|
Code: "Без проекта",
|
||||||
|
Name: ptrString("Без проекта"),
|
||||||
IsActive: true,
|
IsActive: true,
|
||||||
IsSystem: true,
|
IsSystem: true,
|
||||||
CreatedAt: now,
|
CreatedAt: now,
|
||||||
@@ -295,7 +392,8 @@ func (l *LocalDB) ConsolidateSystemProjects() (int64, error) {
|
|||||||
canonical = LocalProject{
|
canonical = LocalProject{
|
||||||
UUID: uuidpkg.NewString(),
|
UUID: uuidpkg.NewString(),
|
||||||
OwnerUsername: "",
|
OwnerUsername: "",
|
||||||
Name: "Без проекта",
|
Code: "Без проекта",
|
||||||
|
Name: ptrString("Без проекта"),
|
||||||
IsActive: true,
|
IsActive: true,
|
||||||
IsSystem: true,
|
IsSystem: true,
|
||||||
CreatedAt: now,
|
CreatedAt: now,
|
||||||
@@ -376,6 +474,10 @@ WHERE (
|
|||||||
return tx.RowsAffected, tx.Error
|
return tx.RowsAffected, tx.Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ptrString(value string) *string {
|
||||||
|
return &value
|
||||||
|
}
|
||||||
|
|
||||||
// BackfillConfigurationProjects ensures every configuration has project_uuid set.
|
// BackfillConfigurationProjects ensures every configuration has project_uuid set.
|
||||||
// If missing, it assigns system project "Без проекта" for configuration owner.
|
// If missing, it assigns system project "Без проекта" for configuration owner.
|
||||||
func (l *LocalDB) BackfillConfigurationProjects(defaultOwner string) error {
|
func (l *LocalDB) BackfillConfigurationProjects(defaultOwner string) error {
|
||||||
|
|||||||
@@ -51,8 +51,8 @@ func TestRunLocalMigrationsBackfillsDefaultProject(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("get system project: %v", err)
|
t.Fatalf("get system project: %v", err)
|
||||||
}
|
}
|
||||||
if project.Name != "Без проекта" {
|
if project.Name == nil || *project.Name != "Без проекта" {
|
||||||
t.Fatalf("expected system project name, got %q", project.Name)
|
t.Fatalf("expected system project name, got %v", project.Name)
|
||||||
}
|
}
|
||||||
if !project.IsSystem {
|
if !project.IsSystem {
|
||||||
t.Fatalf("expected system project flag")
|
t.Fatalf("expected system project flag")
|
||||||
|
|||||||
@@ -88,6 +88,21 @@ var localMigrations = []localMigration{
|
|||||||
name: "Add support_code to local_configurations",
|
name: "Add support_code to local_configurations",
|
||||||
run: addLocalConfigurationSupportCode,
|
run: addLocalConfigurationSupportCode,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "2026_02_13_local_project_code",
|
||||||
|
name: "Add project code to local_projects and backfill",
|
||||||
|
run: addLocalProjectCode,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "2026_02_13_local_project_variant",
|
||||||
|
name: "Add project variant to local_projects and backfill",
|
||||||
|
run: addLocalProjectVariant,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "2026_02_13_local_project_name_nullable",
|
||||||
|
name: "Allow NULL project names in local_projects",
|
||||||
|
run: allowLocalProjectNameNull,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
func runLocalMigrations(db *gorm.DB) error {
|
func runLocalMigrations(db *gorm.DB) error {
|
||||||
@@ -224,7 +239,8 @@ func ensureDefaultProjectTx(tx *gorm.DB, ownerUsername string) (*LocalProject, e
|
|||||||
project = LocalProject{
|
project = LocalProject{
|
||||||
UUID: uuid.NewString(),
|
UUID: uuid.NewString(),
|
||||||
OwnerUsername: ownerUsername,
|
OwnerUsername: ownerUsername,
|
||||||
Name: "Без проекта",
|
Code: "Без проекта",
|
||||||
|
Name: ptrString("Без проекта"),
|
||||||
IsActive: true,
|
IsActive: true,
|
||||||
IsSystem: true,
|
IsSystem: true,
|
||||||
CreatedAt: now,
|
CreatedAt: now,
|
||||||
@@ -238,6 +254,139 @@ func ensureDefaultProjectTx(tx *gorm.DB, ownerUsername string) (*LocalProject, e
|
|||||||
return &project, nil
|
return &project, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func addLocalProjectCode(tx *gorm.DB) error {
|
||||||
|
if err := tx.Exec(`ALTER TABLE local_projects ADD COLUMN code TEXT`).Error; err != nil {
|
||||||
|
if !strings.Contains(strings.ToLower(err.Error()), "duplicate") &&
|
||||||
|
!strings.Contains(strings.ToLower(err.Error()), "exists") {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drop unique index if it already exists to allow de-duplication updates.
|
||||||
|
if err := tx.Exec(`DROP INDEX IF EXISTS idx_local_projects_code`).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy code from current project name.
|
||||||
|
if err := tx.Exec(`
|
||||||
|
UPDATE local_projects
|
||||||
|
SET code = TRIM(COALESCE(name, ''))`).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure any remaining blanks have a unique fallback.
|
||||||
|
if err := tx.Exec(`
|
||||||
|
UPDATE local_projects
|
||||||
|
SET code = 'P-' || uuid
|
||||||
|
WHERE code IS NULL OR TRIM(code) = ''`).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// De-duplicate codes: OPS-1948-2, OPS-1948-3...
|
||||||
|
if err := tx.Exec(`
|
||||||
|
WITH ranked AS (
|
||||||
|
SELECT id, code,
|
||||||
|
ROW_NUMBER() OVER (PARTITION BY code ORDER BY id) AS rn
|
||||||
|
FROM local_projects
|
||||||
|
)
|
||||||
|
UPDATE local_projects
|
||||||
|
SET code = code || '-' || (SELECT rn FROM ranked WHERE ranked.id = local_projects.id)
|
||||||
|
WHERE id IN (SELECT id FROM ranked WHERE rn > 1)`).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create unique index for project codes (ignore if exists).
|
||||||
|
if err := tx.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_local_projects_code ON local_projects(code)`).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func addLocalProjectVariant(tx *gorm.DB) error {
|
||||||
|
if err := tx.Exec(`ALTER TABLE local_projects ADD COLUMN variant TEXT NOT NULL DEFAULT ''`).Error; err != nil {
|
||||||
|
if !strings.Contains(strings.ToLower(err.Error()), "duplicate") &&
|
||||||
|
!strings.Contains(strings.ToLower(err.Error()), "exists") {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drop legacy code index if present.
|
||||||
|
if err := tx.Exec(`DROP INDEX IF EXISTS idx_local_projects_code`).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset code from name and clear variant.
|
||||||
|
if err := tx.Exec(`
|
||||||
|
UPDATE local_projects
|
||||||
|
SET code = TRIM(COALESCE(name, '')),
|
||||||
|
variant = ''`).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// De-duplicate by assigning variant numbers: 2,3...
|
||||||
|
if err := tx.Exec(`
|
||||||
|
WITH ranked AS (
|
||||||
|
SELECT id, code,
|
||||||
|
ROW_NUMBER() OVER (PARTITION BY code ORDER BY id) AS rn
|
||||||
|
FROM local_projects
|
||||||
|
)
|
||||||
|
UPDATE local_projects
|
||||||
|
SET variant = CASE
|
||||||
|
WHEN (SELECT rn FROM ranked WHERE ranked.id = local_projects.id) = 1 THEN ''
|
||||||
|
ELSE '-' || CAST((SELECT rn FROM ranked WHERE ranked.id = local_projects.id) AS TEXT)
|
||||||
|
END`).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_local_projects_code_variant ON local_projects(code, variant)`).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func allowLocalProjectNameNull(tx *gorm.DB) error {
|
||||||
|
if err := tx.Exec(`ALTER TABLE local_projects RENAME TO local_projects_old`).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Exec(`
|
||||||
|
CREATE TABLE local_projects (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
uuid TEXT NOT NULL UNIQUE,
|
||||||
|
server_id INTEGER NULL,
|
||||||
|
owner_username TEXT NOT NULL,
|
||||||
|
code TEXT NOT NULL,
|
||||||
|
variant TEXT NOT NULL DEFAULT '',
|
||||||
|
name TEXT NULL,
|
||||||
|
tracker_url TEXT NULL,
|
||||||
|
is_active INTEGER NOT NULL DEFAULT 1,
|
||||||
|
is_system INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at DATETIME,
|
||||||
|
updated_at DATETIME,
|
||||||
|
synced_at DATETIME NULL,
|
||||||
|
sync_status TEXT DEFAULT 'local'
|
||||||
|
)`).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = tx.Exec(`CREATE INDEX IF NOT EXISTS idx_local_projects_owner_username ON local_projects(owner_username)`).Error
|
||||||
|
_ = tx.Exec(`CREATE INDEX IF NOT EXISTS idx_local_projects_is_active ON local_projects(is_active)`).Error
|
||||||
|
_ = tx.Exec(`CREATE INDEX IF NOT EXISTS idx_local_projects_is_system ON local_projects(is_system)`).Error
|
||||||
|
_ = tx.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_local_projects_code_variant ON local_projects(code, variant)`).Error
|
||||||
|
|
||||||
|
if err := tx.Exec(`
|
||||||
|
INSERT INTO local_projects (id, uuid, server_id, owner_username, code, variant, name, tracker_url, is_active, is_system, created_at, updated_at, synced_at, sync_status)
|
||||||
|
SELECT id, uuid, server_id, owner_username, code, variant, name, tracker_url, is_active, is_system, created_at, updated_at, synced_at, sync_status
|
||||||
|
FROM local_projects_old`).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = tx.Exec(`DROP TABLE local_projects_old`).Error
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func backfillConfigurationPricelists(tx *gorm.DB) error {
|
func backfillConfigurationPricelists(tx *gorm.DB) error {
|
||||||
var latest LocalPricelist
|
var latest LocalPricelist
|
||||||
if err := tx.Where("source = ?", "estimate").Order("created_at DESC").First(&latest).Error; err != nil {
|
if err := tx.Where("source = ?", "estimate").Order("created_at DESC").First(&latest).Error; err != nil {
|
||||||
@@ -279,6 +428,7 @@ func chooseNonZeroTime(candidate time.Time, fallback time.Time) time.Time {
|
|||||||
return candidate
|
return candidate
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
func fixLocalPricelistIndexes(tx *gorm.DB) error {
|
func fixLocalPricelistIndexes(tx *gorm.DB) error {
|
||||||
type indexRow struct {
|
type indexRow struct {
|
||||||
Name string `gorm:"column:name"`
|
Name string `gorm:"column:name"`
|
||||||
|
|||||||
@@ -123,7 +123,9 @@ type LocalProject struct {
|
|||||||
UUID string `gorm:"uniqueIndex;not null" json:"uuid"`
|
UUID string `gorm:"uniqueIndex;not null" json:"uuid"`
|
||||||
ServerID *uint `json:"server_id,omitempty"`
|
ServerID *uint `json:"server_id,omitempty"`
|
||||||
OwnerUsername string `gorm:"not null;index" json:"owner_username"`
|
OwnerUsername string `gorm:"not null;index" json:"owner_username"`
|
||||||
Name string `gorm:"not null" json:"name"`
|
Code string `gorm:"not null;index:idx_local_projects_code_variant,priority:1" json:"code"`
|
||||||
|
Variant string `gorm:"default:'';index:idx_local_projects_code_variant,priority:2" json:"variant"`
|
||||||
|
Name *string `json:"name,omitempty"`
|
||||||
TrackerURL string `json:"tracker_url"`
|
TrackerURL string `json:"tracker_url"`
|
||||||
IsActive bool `gorm:"default:true;index" json:"is_active"`
|
IsActive bool `gorm:"default:true;index" json:"is_active"`
|
||||||
IsSystem bool `gorm:"default:false;index" json:"is_system"`
|
IsSystem bool `gorm:"default:false;index" json:"is_system"`
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ type Project struct {
|
|||||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||||
UUID string `gorm:"size:36;uniqueIndex;not null" json:"uuid"`
|
UUID string `gorm:"size:36;uniqueIndex;not null" json:"uuid"`
|
||||||
OwnerUsername string `gorm:"size:100;not null;index" json:"owner_username"`
|
OwnerUsername string `gorm:"size:100;not null;index" json:"owner_username"`
|
||||||
Name string `gorm:"size:200;not null" json:"name"`
|
Code string `gorm:"size:100;not null;index:idx_qt_projects_code_variant,priority:1" json:"code"`
|
||||||
|
Variant string `gorm:"size:100;not null;default:'';index:idx_qt_projects_code_variant,priority:2" json:"variant"`
|
||||||
|
Name *string `gorm:"size:200" json:"name,omitempty"`
|
||||||
TrackerURL string `gorm:"size:500" json:"tracker_url"`
|
TrackerURL string `gorm:"size:500" json:"tracker_url"`
|
||||||
IsActive bool `gorm:"default:true;index" json:"is_active"`
|
IsActive bool `gorm:"default:true;index" json:"is_active"`
|
||||||
IsSystem bool `gorm:"default:false;index" json:"is_system"`
|
IsSystem bool `gorm:"default:false;index" json:"is_system"`
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ func (r *ProjectRepository) UpsertByUUID(project *models.Project) error {
|
|||||||
Columns: []clause.Column{{Name: "uuid"}},
|
Columns: []clause.Column{{Name: "uuid"}},
|
||||||
DoUpdates: clause.AssignmentColumns([]string{
|
DoUpdates: clause.AssignmentColumns([]string{
|
||||||
"owner_username",
|
"owner_username",
|
||||||
|
"code",
|
||||||
|
"variant",
|
||||||
"name",
|
"name",
|
||||||
"tracker_url",
|
"tracker_url",
|
||||||
"is_active",
|
"is_active",
|
||||||
|
|||||||
@@ -191,7 +191,8 @@ func TestUpdateNoAuthKeepsProjectWhenProjectUUIDOmitted(t *testing.T) {
|
|||||||
project := &localdb.LocalProject{
|
project := &localdb.LocalProject{
|
||||||
UUID: "project-keep",
|
UUID: "project-keep",
|
||||||
OwnerUsername: "tester",
|
OwnerUsername: "tester",
|
||||||
Name: "Keep Project",
|
Code: "TEST-KEEP",
|
||||||
|
Name: ptrString("Keep Project"),
|
||||||
IsActive: true,
|
IsActive: true,
|
||||||
CreatedAt: time.Now(),
|
CreatedAt: time.Now(),
|
||||||
UpdatedAt: time.Now(),
|
UpdatedAt: time.Now(),
|
||||||
@@ -227,6 +228,10 @@ func TestUpdateNoAuthKeepsProjectWhenProjectUUIDOmitted(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ptrString(value string) *string {
|
||||||
|
return &value
|
||||||
|
}
|
||||||
|
|
||||||
func newLocalConfigServiceForTest(t *testing.T) (*LocalConfigurationService, *localdb.LocalDB) {
|
func newLocalConfigServiceForTest(t *testing.T) (*LocalConfigurationService, *localdb.LocalDB) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ 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")
|
||||||
ErrProjectNameExists = errors.New("project name already exists")
|
ErrProjectCodeExists = errors.New("project code and variant already exist")
|
||||||
)
|
)
|
||||||
|
|
||||||
type ProjectService struct {
|
type ProjectService struct {
|
||||||
@@ -30,12 +30,16 @@ func NewProjectService(localDB *localdb.LocalDB) *ProjectService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type CreateProjectRequest struct {
|
type CreateProjectRequest struct {
|
||||||
Name string `json:"name"`
|
Code string `json:"code"`
|
||||||
|
Variant string `json:"variant,omitempty"`
|
||||||
|
Name *string `json:"name,omitempty"`
|
||||||
TrackerURL string `json:"tracker_url"`
|
TrackerURL string `json:"tracker_url"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type UpdateProjectRequest struct {
|
type UpdateProjectRequest struct {
|
||||||
Name string `json:"name"`
|
Code *string `json:"code,omitempty"`
|
||||||
|
Variant *string `json:"variant,omitempty"`
|
||||||
|
Name *string `json:"name,omitempty"`
|
||||||
TrackerURL *string `json:"tracker_url,omitempty"`
|
TrackerURL *string `json:"tracker_url,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,11 +50,19 @@ type ProjectConfigurationsResult struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *ProjectService) Create(ownerUsername string, req *CreateProjectRequest) (*models.Project, error) {
|
func (s *ProjectService) Create(ownerUsername string, req *CreateProjectRequest) (*models.Project, error) {
|
||||||
name := strings.TrimSpace(req.Name)
|
var namePtr *string
|
||||||
if name == "" {
|
if req.Name != nil {
|
||||||
return nil, fmt.Errorf("project name is required")
|
name := strings.TrimSpace(*req.Name)
|
||||||
|
if name != "" {
|
||||||
|
namePtr = &name
|
||||||
}
|
}
|
||||||
if err := s.ensureUniqueProjectName("", name); err != nil {
|
}
|
||||||
|
code := strings.TrimSpace(req.Code)
|
||||||
|
if code == "" {
|
||||||
|
return nil, fmt.Errorf("project code is required")
|
||||||
|
}
|
||||||
|
variant := strings.TrimSpace(req.Variant)
|
||||||
|
if err := s.ensureUniqueProjectCodeVariant("", code, variant); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,8 +70,10 @@ func (s *ProjectService) Create(ownerUsername string, req *CreateProjectRequest)
|
|||||||
localProject := &localdb.LocalProject{
|
localProject := &localdb.LocalProject{
|
||||||
UUID: uuid.NewString(),
|
UUID: uuid.NewString(),
|
||||||
OwnerUsername: ownerUsername,
|
OwnerUsername: ownerUsername,
|
||||||
Name: name,
|
Code: code,
|
||||||
TrackerURL: normalizeProjectTrackerURL(name, req.TrackerURL),
|
Variant: variant,
|
||||||
|
Name: namePtr,
|
||||||
|
TrackerURL: normalizeProjectTrackerURL(code, req.TrackerURL),
|
||||||
IsActive: true,
|
IsActive: true,
|
||||||
IsSystem: false,
|
IsSystem: false,
|
||||||
CreatedAt: now,
|
CreatedAt: now,
|
||||||
@@ -81,19 +95,32 @@ func (s *ProjectService) Update(projectUUID, ownerUsername string, req *UpdatePr
|
|||||||
return nil, ErrProjectNotFound
|
return nil, ErrProjectNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
name := strings.TrimSpace(req.Name)
|
if req.Code != nil {
|
||||||
if name == "" {
|
code := strings.TrimSpace(*req.Code)
|
||||||
return nil, fmt.Errorf("project name is required")
|
if code == "" {
|
||||||
|
return nil, fmt.Errorf("project code is required")
|
||||||
}
|
}
|
||||||
if err := s.ensureUniqueProjectName(projectUUID, name); err != nil {
|
localProject.Code = code
|
||||||
|
}
|
||||||
|
if req.Variant != nil {
|
||||||
|
localProject.Variant = strings.TrimSpace(*req.Variant)
|
||||||
|
}
|
||||||
|
if err := s.ensureUniqueProjectCodeVariant(projectUUID, localProject.Code, localProject.Variant); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
localProject.Name = name
|
if req.Name != nil {
|
||||||
|
name := strings.TrimSpace(*req.Name)
|
||||||
|
if name == "" {
|
||||||
|
localProject.Name = nil
|
||||||
|
} else {
|
||||||
|
localProject.Name = &name
|
||||||
|
}
|
||||||
|
}
|
||||||
if req.TrackerURL != nil {
|
if req.TrackerURL != nil {
|
||||||
localProject.TrackerURL = normalizeProjectTrackerURL(name, *req.TrackerURL)
|
localProject.TrackerURL = normalizeProjectTrackerURL(localProject.Code, *req.TrackerURL)
|
||||||
} else if strings.TrimSpace(localProject.TrackerURL) == "" {
|
} else if strings.TrimSpace(localProject.TrackerURL) == "" {
|
||||||
localProject.TrackerURL = normalizeProjectTrackerURL(name, "")
|
localProject.TrackerURL = normalizeProjectTrackerURL(localProject.Code, "")
|
||||||
}
|
}
|
||||||
localProject.UpdatedAt = time.Now()
|
localProject.UpdatedAt = time.Now()
|
||||||
localProject.SyncStatus = "pending"
|
localProject.SyncStatus = "pending"
|
||||||
@@ -106,10 +133,11 @@ func (s *ProjectService) Update(projectUUID, ownerUsername string, req *UpdatePr
|
|||||||
return localdb.LocalToProject(localProject), nil
|
return localdb.LocalToProject(localProject), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ProjectService) ensureUniqueProjectName(excludeUUID, name string) error {
|
func (s *ProjectService) ensureUniqueProjectCodeVariant(excludeUUID, code, variant string) error {
|
||||||
normalized := normalizeProjectName(name)
|
normalizedCode := normalizeProjectCode(code)
|
||||||
if normalized == "" {
|
normalizedVariant := normalizeProjectVariant(variant)
|
||||||
return fmt.Errorf("project name is required")
|
if normalizedCode == "" {
|
||||||
|
return fmt.Errorf("project code is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
projects, err := s.localDB.GetAllProjects(true)
|
projects, err := s.localDB.GetAllProjects(true)
|
||||||
@@ -121,15 +149,20 @@ func (s *ProjectService) ensureUniqueProjectName(excludeUUID, name string) error
|
|||||||
if excludeUUID != "" && project.UUID == excludeUUID {
|
if excludeUUID != "" && project.UUID == excludeUUID {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if normalizeProjectName(project.Name) == normalized {
|
if normalizeProjectCode(project.Code) == normalizedCode &&
|
||||||
return ErrProjectNameExists
|
normalizeProjectVariant(project.Variant) == normalizedVariant {
|
||||||
|
return ErrProjectCodeExists
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizeProjectName(name string) string {
|
func normalizeProjectCode(code string) string {
|
||||||
return strings.ToLower(strings.TrimSpace(name))
|
return strings.ToLower(strings.TrimSpace(code))
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeProjectVariant(variant string) string {
|
||||||
|
return strings.ToLower(strings.TrimSpace(variant))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ProjectService) Archive(projectUUID, ownerUsername string) error {
|
func (s *ProjectService) Archive(projectUUID, ownerUsername string) error {
|
||||||
|
|||||||
@@ -200,6 +200,7 @@ func (s *Service) ImportProjectsToLocal() (*ProjectImportResult, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
existing.OwnerUsername = project.OwnerUsername
|
existing.OwnerUsername = project.OwnerUsername
|
||||||
|
existing.Code = project.Code
|
||||||
existing.Name = project.Name
|
existing.Name = project.Name
|
||||||
existing.TrackerURL = project.TrackerURL
|
existing.TrackerURL = project.TrackerURL
|
||||||
existing.IsActive = project.IsActive
|
existing.IsActive = project.IsActive
|
||||||
@@ -848,6 +849,12 @@ func (s *Service) pushProjectChange(change *localdb.PendingChange) error {
|
|||||||
projectRepo := repository.NewProjectRepository(mariaDB)
|
projectRepo := repository.NewProjectRepository(mariaDB)
|
||||||
project := payload.Snapshot
|
project := payload.Snapshot
|
||||||
project.UUID = payload.ProjectUUID
|
project.UUID = payload.ProjectUUID
|
||||||
|
if strings.TrimSpace(project.Code) == "" {
|
||||||
|
project.Code = strings.TrimSpace(derefString(project.Name))
|
||||||
|
if project.Code == "" {
|
||||||
|
project.Code = project.UUID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if err := projectRepo.UpsertByUUID(&project); err != nil {
|
if err := projectRepo.UpsertByUUID(&project); err != nil {
|
||||||
return fmt.Errorf("upsert project on server: %w", err)
|
return fmt.Errorf("upsert project on server: %w", err)
|
||||||
@@ -868,6 +875,17 @@ func (s *Service) pushProjectChange(change *localdb.PendingChange) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func derefString(value *string) string {
|
||||||
|
if value == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return *value
|
||||||
|
}
|
||||||
|
|
||||||
|
func ptrString(value string) *string {
|
||||||
|
return &value
|
||||||
|
}
|
||||||
|
|
||||||
func decodeProjectChangePayload(change *localdb.PendingChange) (ProjectChangePayload, error) {
|
func decodeProjectChangePayload(change *localdb.PendingChange) (ProjectChangePayload, error) {
|
||||||
var payload ProjectChangePayload
|
var payload ProjectChangePayload
|
||||||
if err := json.Unmarshal([]byte(change.Payload), &payload); err == nil && payload.ProjectUUID != "" {
|
if err := json.Unmarshal([]byte(change.Payload), &payload); err == nil && payload.ProjectUUID != "" {
|
||||||
@@ -1138,7 +1156,8 @@ func (s *Service) ensureConfigurationProject(mariaDB *gorm.DB, cfg *models.Confi
|
|||||||
systemProject = &models.Project{
|
systemProject = &models.Project{
|
||||||
UUID: uuid.NewString(),
|
UUID: uuid.NewString(),
|
||||||
OwnerUsername: "",
|
OwnerUsername: "",
|
||||||
Name: "Без проекта",
|
Code: "Без проекта",
|
||||||
|
Name: ptrString("Без проекта"),
|
||||||
IsActive: true,
|
IsActive: true,
|
||||||
IsSystem: true,
|
IsSystem: true,
|
||||||
}
|
}
|
||||||
@@ -1302,6 +1321,21 @@ func (s *Service) loadCurrentConfigurationState(configurationUUID string) (model
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if currentVersionNo == 0 {
|
||||||
|
if err := s.repairMissingConfigurationVersion(localCfg); err != nil {
|
||||||
|
return models.Configuration{}, "", 0, fmt.Errorf("repair missing configuration version: %w", err)
|
||||||
|
}
|
||||||
|
var latest localdb.LocalConfigurationVersion
|
||||||
|
err = s.localDB.DB().
|
||||||
|
Where("configuration_uuid = ?", configurationUUID).
|
||||||
|
Order("version_no DESC").
|
||||||
|
First(&latest).Error
|
||||||
|
if err == nil {
|
||||||
|
currentVersionNo = latest.VersionNo
|
||||||
|
currentVersionID = latest.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if currentVersionNo == 0 {
|
if currentVersionNo == 0 {
|
||||||
return models.Configuration{}, "", 0, fmt.Errorf("no local configuration version found for %s", configurationUUID)
|
return models.Configuration{}, "", 0, fmt.Errorf("no local configuration version found for %s", configurationUUID)
|
||||||
}
|
}
|
||||||
@@ -1309,6 +1343,64 @@ func (s *Service) loadCurrentConfigurationState(configurationUUID string) (model
|
|||||||
return cfg, currentVersionID, currentVersionNo, nil
|
return cfg, currentVersionID, currentVersionNo, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) repairMissingConfigurationVersion(localCfg *localdb.LocalConfiguration) error {
|
||||||
|
if localCfg == nil {
|
||||||
|
return fmt.Errorf("local configuration is nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.localDB.DB().Transaction(func(tx *gorm.DB) error {
|
||||||
|
var cfg localdb.LocalConfiguration
|
||||||
|
if err := tx.Where("uuid = ?", localCfg.UUID).First(&cfg).Error; err != nil {
|
||||||
|
return fmt.Errorf("load local configuration: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If versions exist, just make sure current_version_id is set.
|
||||||
|
var latest localdb.LocalConfigurationVersion
|
||||||
|
if err := tx.Where("configuration_uuid = ?", cfg.UUID).
|
||||||
|
Order("version_no DESC").
|
||||||
|
First(&latest).Error; err == nil {
|
||||||
|
if cfg.CurrentVersionID == nil || *cfg.CurrentVersionID == "" {
|
||||||
|
if err := tx.Model(&localdb.LocalConfiguration{}).
|
||||||
|
Where("uuid = ?", cfg.UUID).
|
||||||
|
Update("current_version_id", latest.ID).Error; err != nil {
|
||||||
|
return fmt.Errorf("set current version id: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return fmt.Errorf("load latest version: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshot, err := localdb.BuildConfigurationSnapshot(&cfg)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("build configuration snapshot: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
note := "Auto-repaired missing local version"
|
||||||
|
version := localdb.LocalConfigurationVersion{
|
||||||
|
ID: uuid.NewString(),
|
||||||
|
ConfigurationUUID: cfg.UUID,
|
||||||
|
VersionNo: 1,
|
||||||
|
Data: snapshot,
|
||||||
|
ChangeNote: ¬e,
|
||||||
|
AppVersion: appmeta.Version(),
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Create(&version).Error; err != nil {
|
||||||
|
return fmt.Errorf("create initial version: %w", err)
|
||||||
|
}
|
||||||
|
if err := tx.Model(&localdb.LocalConfiguration{}).
|
||||||
|
Where("uuid = ?", cfg.UUID).
|
||||||
|
Update("current_version_id", version.ID).Error; err != nil {
|
||||||
|
return fmt.Errorf("set current version id: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Warn("repaired missing local configuration version", "uuid", cfg.UUID, "version_no", version.VersionNo)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// NOTE: prepared for future conflict resolution:
|
// NOTE: prepared for future conflict resolution:
|
||||||
// when server starts storing version metadata, we can compare payload.CurrentVersionNo
|
// when server starts storing version metadata, we can compare payload.CurrentVersionNo
|
||||||
// against remote version and branch into custom strategies. For now use last-write-wins.
|
// against remote version and branch into custom strategies. For now use last-write-wins.
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ func TestPushPendingChangesProjectsBeforeConfigurations(t *testing.T) {
|
|||||||
projectService := services.NewProjectService(local)
|
projectService := services.NewProjectService(local)
|
||||||
configService := services.NewLocalConfigurationService(local, localSync, &services.QuoteService{}, func() bool { return false })
|
configService := services.NewLocalConfigurationService(local, localSync, &services.QuoteService{}, func() bool { return false })
|
||||||
|
|
||||||
project, err := projectService.Create("tester", &services.CreateProjectRequest{Name: "Project A"})
|
project, err := projectService.Create("tester", &services.CreateProjectRequest{Name: ptrString("Project A"), Code: "PRJ-A"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("create project: %v", err)
|
t.Fatalf("create project: %v", err)
|
||||||
}
|
}
|
||||||
@@ -74,11 +74,11 @@ func TestPushPendingChangesProjectCreateThenUpdateBeforeFirstPush(t *testing.T)
|
|||||||
configService := services.NewLocalConfigurationService(local, localSync, &services.QuoteService{}, func() bool { return false })
|
configService := services.NewLocalConfigurationService(local, localSync, &services.QuoteService{}, func() bool { return false })
|
||||||
pushService := syncsvc.NewServiceWithDB(serverDB, local)
|
pushService := syncsvc.NewServiceWithDB(serverDB, local)
|
||||||
|
|
||||||
project, err := projectService.Create("tester", &services.CreateProjectRequest{Name: "Project v1"})
|
project, err := projectService.Create("tester", &services.CreateProjectRequest{Name: ptrString("Project v1"), Code: "PRJ-V1"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("create project: %v", err)
|
t.Fatalf("create project: %v", err)
|
||||||
}
|
}
|
||||||
if _, err := projectService.Update(project.UUID, "tester", &services.UpdateProjectRequest{Name: "Project v2"}); err != nil {
|
if _, err := projectService.Update(project.UUID, "tester", &services.UpdateProjectRequest{Name: ptrString("Project v2")}); err != nil {
|
||||||
t.Fatalf("update project: %v", err)
|
t.Fatalf("update project: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,8 +100,8 @@ func TestPushPendingChangesProjectCreateThenUpdateBeforeFirstPush(t *testing.T)
|
|||||||
if err := serverDB.Where("uuid = ?", project.UUID).First(&serverProject).Error; err != nil {
|
if err := serverDB.Where("uuid = ?", project.UUID).First(&serverProject).Error; err != nil {
|
||||||
t.Fatalf("project not pushed to server: %v", err)
|
t.Fatalf("project not pushed to server: %v", err)
|
||||||
}
|
}
|
||||||
if serverProject.Name != "Project v2" {
|
if serverProject.Name == nil || *serverProject.Name != "Project v2" {
|
||||||
t.Fatalf("expected latest project name, got %q", serverProject.Name)
|
t.Fatalf("expected latest project name, got %v", serverProject.Name)
|
||||||
}
|
}
|
||||||
|
|
||||||
var serverCfg models.Configuration
|
var serverCfg models.Configuration
|
||||||
@@ -324,6 +324,8 @@ CREATE TABLE qt_projects (
|
|||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
uuid TEXT NOT NULL UNIQUE,
|
uuid TEXT NOT NULL UNIQUE,
|
||||||
owner_username TEXT NOT NULL,
|
owner_username TEXT NOT NULL,
|
||||||
|
code TEXT NOT NULL,
|
||||||
|
variant TEXT NOT NULL DEFAULT '',
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
tracker_url TEXT NULL,
|
tracker_url TEXT NULL,
|
||||||
is_active INTEGER NOT NULL DEFAULT 1,
|
is_active INTEGER NOT NULL DEFAULT 1,
|
||||||
@@ -333,6 +335,9 @@ CREATE TABLE qt_projects (
|
|||||||
);`).Error; err != nil {
|
);`).Error; err != nil {
|
||||||
t.Fatalf("create qt_projects: %v", err)
|
t.Fatalf("create qt_projects: %v", err)
|
||||||
}
|
}
|
||||||
|
if err := db.Exec(`CREATE UNIQUE INDEX idx_qt_projects_code_variant ON qt_projects(code, variant);`).Error; err != nil {
|
||||||
|
t.Fatalf("create qt_projects index: %v", err)
|
||||||
|
}
|
||||||
if err := db.Exec(`
|
if err := db.Exec(`
|
||||||
CREATE TABLE qt_configurations (
|
CREATE TABLE qt_configurations (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
@@ -364,6 +369,10 @@ CREATE TABLE qt_configurations (
|
|||||||
return db
|
return db
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ptrString(value string) *string {
|
||||||
|
return &value
|
||||||
|
}
|
||||||
|
|
||||||
func getCurrentVersionInfo(t *testing.T, local *localdb.LocalDB, configurationUUID string, currentVersionID *string) (int, string) {
|
func getCurrentVersionInfo(t *testing.T, local *localdb.LocalDB, configurationUUID string, currentVersionID *string) (int, string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
if currentVersionID == nil || *currentVersionID == "" {
|
if currentVersionID == nil || *currentVersionID == "" {
|
||||||
|
|||||||
38
migrations/025_add_project_code.sql
Normal file
38
migrations/025_add_project_code.sql
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
-- Add project code and enforce uniqueness
|
||||||
|
|
||||||
|
ALTER TABLE qt_projects
|
||||||
|
ADD COLUMN code VARCHAR(100) NULL AFTER owner_username;
|
||||||
|
|
||||||
|
-- Copy code from current project name (truncate to fit)
|
||||||
|
UPDATE qt_projects
|
||||||
|
SET code = LEFT(TRIM(COALESCE(name, '')), 100);
|
||||||
|
|
||||||
|
-- Fallback for any remaining blanks
|
||||||
|
UPDATE qt_projects
|
||||||
|
SET code = uuid
|
||||||
|
WHERE code IS NULL OR TRIM(code) = '';
|
||||||
|
|
||||||
|
-- Drop unique index if it already exists to allow de-duplication updates
|
||||||
|
DROP INDEX IF EXISTS idx_qt_projects_code ON qt_projects;
|
||||||
|
|
||||||
|
-- De-duplicate codes: OPS-1948-2, OPS-1948-3... (MariaDB without CTE)
|
||||||
|
UPDATE qt_projects p
|
||||||
|
JOIN (
|
||||||
|
SELECT p1.id,
|
||||||
|
p1.code AS base_code,
|
||||||
|
(
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM qt_projects p2
|
||||||
|
WHERE p2.code = p1.code AND p2.id <= p1.id
|
||||||
|
) AS rn
|
||||||
|
FROM qt_projects p1
|
||||||
|
) r ON r.id = p.id
|
||||||
|
SET p.code = CASE
|
||||||
|
WHEN r.rn = 1 THEN r.base_code
|
||||||
|
ELSE CONCAT(LEFT(r.base_code, 90), '-', r.rn)
|
||||||
|
END;
|
||||||
|
|
||||||
|
ALTER TABLE qt_projects
|
||||||
|
MODIFY COLUMN code VARCHAR(100) NOT NULL;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX idx_qt_projects_code ON qt_projects(code);
|
||||||
28
migrations/026_add_project_variant.sql
Normal file
28
migrations/026_add_project_variant.sql
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
-- Add project variant and reset codes from project names
|
||||||
|
|
||||||
|
ALTER TABLE qt_projects
|
||||||
|
ADD COLUMN variant VARCHAR(100) NOT NULL DEFAULT '' AFTER code;
|
||||||
|
|
||||||
|
-- Drop legacy unique index on code to allow duplicate codes
|
||||||
|
DROP INDEX IF EXISTS idx_qt_projects_code ON qt_projects;
|
||||||
|
DROP INDEX IF EXISTS idx_qt_projects_code_variant ON qt_projects;
|
||||||
|
|
||||||
|
-- Reset code from name and clear variant
|
||||||
|
UPDATE qt_projects
|
||||||
|
SET code = LEFT(TRIM(COALESCE(name, '')), 100),
|
||||||
|
variant = '';
|
||||||
|
|
||||||
|
-- De-duplicate by assigning variant numbers: -2, -3...
|
||||||
|
UPDATE qt_projects p
|
||||||
|
JOIN (
|
||||||
|
SELECT p1.id,
|
||||||
|
p1.code,
|
||||||
|
(SELECT COUNT(*)
|
||||||
|
FROM qt_projects p2
|
||||||
|
WHERE p2.code = p1.code AND p2.id <= p1.id) AS rn
|
||||||
|
FROM qt_projects p1
|
||||||
|
) r ON r.id = p.id
|
||||||
|
SET p.code = r.code,
|
||||||
|
p.variant = CASE WHEN r.rn = 1 THEN '' ELSE CONCAT('-', r.rn) END;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX idx_qt_projects_code_variant ON qt_projects(code, variant);
|
||||||
4
migrations/027_project_name_nullable.sql
Normal file
4
migrations/027_project_name_nullable.sql
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
-- Allow NULL project names
|
||||||
|
|
||||||
|
ALTER TABLE qt_projects
|
||||||
|
MODIFY COLUMN name VARCHAR(200) NULL;
|
||||||
@@ -62,7 +62,7 @@
|
|||||||
<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 id="create-project-input"
|
<input id="create-project-input"
|
||||||
list="create-project-options"
|
list="create-project-options"
|
||||||
placeholder="Начните вводить название проекта"
|
placeholder="Например: OPS-123 (Lenovo)"
|
||||||
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">
|
||||||
<datalist id="create-project-options"></datalist>
|
<datalist id="create-project-options"></datalist>
|
||||||
<div class="mt-2 flex justify-between items-center gap-3">
|
<div class="mt-2 flex justify-between items-center gap-3">
|
||||||
@@ -147,7 +147,7 @@
|
|||||||
<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 id="move-project-input"
|
<input id="move-project-input"
|
||||||
list="move-project-options"
|
list="move-project-options"
|
||||||
placeholder="Начните вводить название проекта"
|
placeholder="Например: OPS-123 (Lenovo)"
|
||||||
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">
|
||||||
<datalist id="move-project-options"></datalist>
|
<datalist id="move-project-options"></datalist>
|
||||||
<div class="mt-2 flex justify-between items-center gap-3">
|
<div class="mt-2 flex justify-between items-center gap-3">
|
||||||
@@ -174,7 +174,17 @@
|
|||||||
<div id="create-project-on-move-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
|
<div id="create-project-on-move-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-3">Проект не найден</h2>
|
<h2 class="text-xl font-semibold mb-3">Проект не найден</h2>
|
||||||
<p class="text-sm text-gray-600 mb-4">Проект "<span id="create-project-on-move-name" class="font-medium text-gray-900"></span>" не найден. <span id="create-project-on-move-description">Создать и привязать квоту?</span></p>
|
<p class="text-sm text-gray-600 mb-4">Проект с кодом "<span id="create-project-on-move-code" class="font-medium text-gray-900"></span>" не найден. <span id="create-project-on-move-description">Создать и привязать квоту?</span></p>
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="create-project-on-move-name" class="block text-sm font-medium text-gray-700 mb-1">Название проекта</label>
|
||||||
|
<input id="create-project-on-move-name" type="text" placeholder="Например: Инфраструктура для OPS-123"
|
||||||
|
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||||
|
</div>
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="create-project-on-move-variant" class="block text-sm font-medium text-gray-700 mb-1">Вариант (необязательно)</label>
|
||||||
|
<input id="create-project-on-move-variant" type="text" placeholder="Например: Lenovo"
|
||||||
|
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||||
|
</div>
|
||||||
<div class="flex justify-end space-x-3">
|
<div class="flex justify-end space-x-3">
|
||||||
<button onclick="closeCreateProjectOnMoveModal()" class="px-4 py-2 text-gray-600 hover:text-gray-800">Отмена</button>
|
<button onclick="closeCreateProjectOnMoveModal()" class="px-4 py-2 text-gray-600 hover:text-gray-800">Отмена</button>
|
||||||
<button id="create-project-on-move-confirm-btn" onclick="confirmCreateProjectOnMove()" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">Создать и привязать</button>
|
<button id="create-project-on-move-confirm-btn" onclick="confirmCreateProjectOnMove()" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">Создать и привязать</button>
|
||||||
@@ -191,10 +201,12 @@ let configStatusMode = 'active';
|
|||||||
let configsSearch = '';
|
let configsSearch = '';
|
||||||
let projectsCache = [];
|
let projectsCache = [];
|
||||||
let projectNameByUUID = {};
|
let projectNameByUUID = {};
|
||||||
|
let projectCodeByUUID = {};
|
||||||
|
let projectVariantByUUID = {};
|
||||||
let pendingMoveConfigUUID = '';
|
let pendingMoveConfigUUID = '';
|
||||||
let pendingMoveProjectName = '';
|
let pendingMoveProjectCode = '';
|
||||||
let pendingCreateConfigName = '';
|
let pendingCreateConfigName = '';
|
||||||
let pendingCreateProjectName = '';
|
let pendingCreateProjectCode = '';
|
||||||
|
|
||||||
function renderConfigs(configs) {
|
function renderConfigs(configs) {
|
||||||
const emptyText = configStatusMode === 'archived'
|
const emptyText = configStatusMode === 'archived'
|
||||||
@@ -307,6 +319,30 @@ function renderConfigs(configs) {
|
|||||||
document.getElementById('configs-list').innerHTML = html;
|
document.getElementById('configs-list').innerHTML = html;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function projectDisplayKey(project) {
|
||||||
|
const code = (project.code || '').trim();
|
||||||
|
const variant = (project.variant || '').trim();
|
||||||
|
if (!code) return '';
|
||||||
|
return variant ? (code + ' (' + variant + ')') : code;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findProjectByInput(input) {
|
||||||
|
const trimmed = (input || '').trim().toLowerCase();
|
||||||
|
if (!trimmed) return null;
|
||||||
|
|
||||||
|
const directMatch = projectsCache.find(p => projectDisplayKey(p).toLowerCase() === trimmed);
|
||||||
|
if (directMatch) return directMatch;
|
||||||
|
|
||||||
|
const codeMatches = projectsCache.filter(p => (p.code || '').toLowerCase() === trimmed);
|
||||||
|
if (codeMatches.length === 1) {
|
||||||
|
return codeMatches[0];
|
||||||
|
}
|
||||||
|
if (codeMatches.length > 1) {
|
||||||
|
alert('У проекта несколько вариантов. Укажите вариант в формате "CODE (variant)".');
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function escapeHtml(text) {
|
function escapeHtml(text) {
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
div.textContent = text;
|
div.textContent = text;
|
||||||
@@ -444,21 +480,21 @@ async function createConfig() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const projectName = document.getElementById('create-project-input').value.trim();
|
const projectCode = document.getElementById('create-project-input').value.trim();
|
||||||
let projectUUID = '';
|
let projectUUID = '';
|
||||||
|
|
||||||
if (projectName) {
|
if (projectCode) {
|
||||||
const matchedProject = projectsCache.find(p => p.name.toLowerCase() === projectName.toLowerCase());
|
const matchedProject = findProjectByInput(projectCode);
|
||||||
if (matchedProject) {
|
if (matchedProject) {
|
||||||
if (!matchedProject.is_active) {
|
if (!matchedProject.is_active) {
|
||||||
alert('Проект с таким названием находится в архиве. Восстановите его или выберите другой.');
|
alert('Проект с таким кодом находится в архиве. Восстановите его или выберите другой.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
projectUUID = matchedProject.uuid;
|
projectUUID = matchedProject.uuid;
|
||||||
} else {
|
} else {
|
||||||
pendingCreateConfigName = name;
|
pendingCreateConfigName = name;
|
||||||
pendingCreateProjectName = projectName;
|
pendingCreateProjectCode = projectCode;
|
||||||
openCreateProjectOnCreateModal(projectName);
|
openCreateProjectOnCreateModal(projectCode);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -506,12 +542,14 @@ function openMoveProjectModal(uuid, configName, currentProjectUUID) {
|
|||||||
projectsCache.forEach(project => {
|
projectsCache.forEach(project => {
|
||||||
if (!project.is_active) return;
|
if (!project.is_active) return;
|
||||||
const option = document.createElement('option');
|
const option = document.createElement('option');
|
||||||
option.value = project.name;
|
option.value = projectDisplayKey(project);
|
||||||
|
option.label = project.name || '';
|
||||||
options.appendChild(option);
|
options.appendChild(option);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (currentProjectUUID && projectNameByUUID[currentProjectUUID]) {
|
if (currentProjectUUID && projectCodeByUUID[currentProjectUUID]) {
|
||||||
input.value = projectNameByUUID[currentProjectUUID];
|
const variant = projectVariantByUUID[currentProjectUUID] || '';
|
||||||
|
input.value = variant ? (projectCodeByUUID[currentProjectUUID] + ' (' + variant + ')') : projectCodeByUUID[currentProjectUUID];
|
||||||
} else {
|
} else {
|
||||||
input.value = '';
|
input.value = '';
|
||||||
}
|
}
|
||||||
@@ -527,23 +565,23 @@ function closeMoveProjectModal() {
|
|||||||
|
|
||||||
async function confirmMoveProject() {
|
async function confirmMoveProject() {
|
||||||
const uuid = document.getElementById('move-project-uuid').value;
|
const uuid = document.getElementById('move-project-uuid').value;
|
||||||
const projectName = document.getElementById('move-project-input').value.trim();
|
const projectCode = document.getElementById('move-project-input').value.trim();
|
||||||
|
|
||||||
if (!uuid) return;
|
if (!uuid) return;
|
||||||
let projectUUID = '';
|
let projectUUID = '';
|
||||||
|
|
||||||
if (projectName) {
|
if (projectCode) {
|
||||||
const matchedProject = projectsCache.find(p => p.name.toLowerCase() === projectName.toLowerCase());
|
const matchedProject = findProjectByInput(projectCode);
|
||||||
if (matchedProject) {
|
if (matchedProject) {
|
||||||
if (!matchedProject.is_active) {
|
if (!matchedProject.is_active) {
|
||||||
alert('Проект с таким названием находится в архиве. Восстановите его или выберите другой.');
|
alert('Проект с таким кодом находится в архиве. Восстановите его или выберите другой.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
projectUUID = matchedProject.uuid;
|
projectUUID = matchedProject.uuid;
|
||||||
} else {
|
} else {
|
||||||
pendingMoveConfigUUID = uuid;
|
pendingMoveConfigUUID = uuid;
|
||||||
pendingMoveProjectName = projectName;
|
pendingMoveProjectCode = projectCode;
|
||||||
openCreateProjectOnMoveModal(projectName);
|
openCreateProjectOnMoveModal(projectCode);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -560,7 +598,9 @@ function clearCreateProjectInput() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openCreateProjectOnMoveModal(projectName) {
|
function openCreateProjectOnMoveModal(projectName) {
|
||||||
document.getElementById('create-project-on-move-name').textContent = projectName;
|
document.getElementById('create-project-on-move-code').textContent = projectName;
|
||||||
|
document.getElementById('create-project-on-move-name').value = projectName;
|
||||||
|
document.getElementById('create-project-on-move-variant').value = '';
|
||||||
document.getElementById('create-project-on-move-description').textContent = 'Создать и привязать квоту?';
|
document.getElementById('create-project-on-move-description').textContent = 'Создать и привязать квоту?';
|
||||||
document.getElementById('create-project-on-move-confirm-btn').textContent = 'Создать и привязать';
|
document.getElementById('create-project-on-move-confirm-btn').textContent = 'Создать и привязать';
|
||||||
document.getElementById('create-project-on-move-modal').classList.remove('hidden');
|
document.getElementById('create-project-on-move-modal').classList.remove('hidden');
|
||||||
@@ -568,7 +608,9 @@ function openCreateProjectOnMoveModal(projectName) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openCreateProjectOnCreateModal(projectName) {
|
function openCreateProjectOnCreateModal(projectName) {
|
||||||
document.getElementById('create-project-on-move-name').textContent = projectName;
|
document.getElementById('create-project-on-move-code').textContent = projectName;
|
||||||
|
document.getElementById('create-project-on-move-name').value = projectName;
|
||||||
|
document.getElementById('create-project-on-move-variant').value = '';
|
||||||
document.getElementById('create-project-on-move-description').textContent = 'Создать и использовать для новой конфигурации?';
|
document.getElementById('create-project-on-move-description').textContent = 'Создать и использовать для новой конфигурации?';
|
||||||
document.getElementById('create-project-on-move-confirm-btn').textContent = 'Создать и использовать';
|
document.getElementById('create-project-on-move-confirm-btn').textContent = 'Создать и использовать';
|
||||||
document.getElementById('create-project-on-move-modal').classList.remove('hidden');
|
document.getElementById('create-project-on-move-modal').classList.remove('hidden');
|
||||||
@@ -579,24 +621,30 @@ function closeCreateProjectOnMoveModal() {
|
|||||||
document.getElementById('create-project-on-move-modal').classList.add('hidden');
|
document.getElementById('create-project-on-move-modal').classList.add('hidden');
|
||||||
document.getElementById('create-project-on-move-modal').classList.remove('flex');
|
document.getElementById('create-project-on-move-modal').classList.remove('flex');
|
||||||
pendingMoveConfigUUID = '';
|
pendingMoveConfigUUID = '';
|
||||||
pendingMoveProjectName = '';
|
pendingMoveProjectCode = '';
|
||||||
pendingCreateConfigName = '';
|
pendingCreateConfigName = '';
|
||||||
pendingCreateProjectName = '';
|
pendingCreateProjectCode = '';
|
||||||
|
document.getElementById('create-project-on-move-name').value = '';
|
||||||
|
document.getElementById('create-project-on-move-variant').value = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
async function confirmCreateProjectOnMove() {
|
async function confirmCreateProjectOnMove() {
|
||||||
if (pendingCreateConfigName && pendingCreateProjectName) {
|
const projectNameInput = document.getElementById('create-project-on-move-name');
|
||||||
|
const projectVariantInput = document.getElementById('create-project-on-move-variant');
|
||||||
|
const projectName = (projectNameInput.value || '').trim();
|
||||||
|
const projectVariant = (projectVariantInput.value || '').trim();
|
||||||
|
if (pendingCreateConfigName && pendingCreateProjectCode) {
|
||||||
const configName = pendingCreateConfigName;
|
const configName = pendingCreateConfigName;
|
||||||
const projectName = pendingCreateProjectName;
|
const projectCode = pendingCreateProjectCode;
|
||||||
try {
|
try {
|
||||||
const createResp = await fetch('/api/projects', {
|
const createResp = await fetch('/api/projects', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {'Content-Type': 'application/json'},
|
headers: {'Content-Type': 'application/json'},
|
||||||
body: JSON.stringify({ name: projectName })
|
body: JSON.stringify({ name: projectName, code: projectCode, variant: projectVariant })
|
||||||
});
|
});
|
||||||
if (!createResp.ok) {
|
if (!createResp.ok) {
|
||||||
if (createResp.status === 409) {
|
if (createResp.status === 409) {
|
||||||
alert('Проект с таким названием уже существует');
|
alert('Проект с таким кодом и вариантом уже существует');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const err = await createResp.json();
|
const err = await createResp.json();
|
||||||
@@ -606,14 +654,14 @@ async function confirmCreateProjectOnMove() {
|
|||||||
|
|
||||||
const newProject = await createResp.json();
|
const newProject = await createResp.json();
|
||||||
pendingCreateConfigName = '';
|
pendingCreateConfigName = '';
|
||||||
pendingCreateProjectName = '';
|
pendingCreateProjectCode = '';
|
||||||
await loadProjectsForConfigUI();
|
await loadProjectsForConfigUI();
|
||||||
const created = await createConfigWithProject(configName, newProject.uuid);
|
const created = await createConfigWithProject(configName, newProject.uuid);
|
||||||
if (created) {
|
if (created) {
|
||||||
closeCreateProjectOnMoveModal();
|
closeCreateProjectOnMoveModal();
|
||||||
} else {
|
} else {
|
||||||
closeCreateProjectOnMoveModal();
|
closeCreateProjectOnMoveModal();
|
||||||
document.getElementById('create-project-input').value = projectName;
|
document.getElementById('create-project-input').value = projectCode;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert('Ошибка создания проекта');
|
alert('Ошибка создания проекта');
|
||||||
@@ -622,8 +670,8 @@ async function confirmCreateProjectOnMove() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const configUUID = pendingMoveConfigUUID;
|
const configUUID = pendingMoveConfigUUID;
|
||||||
const projectName = pendingMoveProjectName;
|
const projectCode = pendingMoveProjectCode;
|
||||||
if (!configUUID || !projectName) {
|
if (!configUUID || !projectCode) {
|
||||||
closeCreateProjectOnMoveModal();
|
closeCreateProjectOnMoveModal();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -632,11 +680,11 @@ async function confirmCreateProjectOnMove() {
|
|||||||
const createResp = await fetch('/api/projects', {
|
const createResp = await fetch('/api/projects', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {'Content-Type': 'application/json'},
|
headers: {'Content-Type': 'application/json'},
|
||||||
body: JSON.stringify({ name: projectName })
|
body: JSON.stringify({ name: projectName, code: projectCode, variant: projectVariant })
|
||||||
});
|
});
|
||||||
if (!createResp.ok) {
|
if (!createResp.ok) {
|
||||||
if (createResp.status === 409) {
|
if (createResp.status === 409) {
|
||||||
alert('Проект с таким названием уже существует');
|
alert('Проект с таким кодом и вариантом уже существует');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const err = await createResp.json();
|
const err = await createResp.json();
|
||||||
@@ -646,9 +694,9 @@ async function confirmCreateProjectOnMove() {
|
|||||||
|
|
||||||
const newProject = await createResp.json();
|
const newProject = await createResp.json();
|
||||||
pendingMoveConfigUUID = '';
|
pendingMoveConfigUUID = '';
|
||||||
pendingMoveProjectName = '';
|
pendingMoveProjectCode = '';
|
||||||
await loadProjectsForConfigUI();
|
await loadProjectsForConfigUI();
|
||||||
document.getElementById('move-project-input').value = projectName;
|
document.getElementById('move-project-input').value = projectCode;
|
||||||
const moved = await moveConfigToProject(configUUID, newProject.uuid);
|
const moved = await moveConfigToProject(configUUID, newProject.uuid);
|
||||||
if (moved) {
|
if (moved) {
|
||||||
closeCreateProjectOnMoveModal();
|
closeCreateProjectOnMoveModal();
|
||||||
@@ -835,6 +883,8 @@ document.getElementById('configs-search').addEventListener('input', function(e)
|
|||||||
async function loadProjectsForConfigUI() {
|
async function loadProjectsForConfigUI() {
|
||||||
projectsCache = [];
|
projectsCache = [];
|
||||||
projectNameByUUID = {};
|
projectNameByUUID = {};
|
||||||
|
projectCodeByUUID = {};
|
||||||
|
projectVariantByUUID = {};
|
||||||
try {
|
try {
|
||||||
// Use /api/projects/all to get all projects without pagination
|
// Use /api/projects/all to get all projects without pagination
|
||||||
const resp = await fetch('/api/projects/all');
|
const resp = await fetch('/api/projects/all');
|
||||||
@@ -847,7 +897,11 @@ async function loadProjectsForConfigUI() {
|
|||||||
projectsCache = allProjects;
|
projectsCache = allProjects;
|
||||||
|
|
||||||
allProjects.forEach(project => {
|
allProjects.forEach(project => {
|
||||||
projectNameByUUID[project.uuid] = project.name;
|
const variant = (project.variant || '').trim();
|
||||||
|
const baseName = project.name || '';
|
||||||
|
projectNameByUUID[project.uuid] = variant ? (baseName + ' (' + variant + ')') : baseName;
|
||||||
|
projectCodeByUUID[project.uuid] = project.code || '';
|
||||||
|
projectVariantByUUID[project.uuid] = project.variant || '';
|
||||||
});
|
});
|
||||||
|
|
||||||
const createOptions = document.getElementById('create-project-options');
|
const createOptions = document.getElementById('create-project-options');
|
||||||
@@ -856,7 +910,8 @@ async function loadProjectsForConfigUI() {
|
|||||||
projectsCache.forEach(project => {
|
projectsCache.forEach(project => {
|
||||||
if (!project.is_active) return;
|
if (!project.is_active) return;
|
||||||
const option = document.createElement('option');
|
const option = document.createElement('option');
|
||||||
option.value = project.name;
|
option.value = projectDisplayKey(project);
|
||||||
|
option.label = project.name || '';
|
||||||
createOptions.appendChild(option);
|
createOptions.appendChild(option);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,14 +5,25 @@
|
|||||||
<!-- Header with config name and back button -->
|
<!-- Header with config name and back button -->
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div class="flex items-center space-x-4">
|
<div class="flex items-center space-x-4">
|
||||||
<a href="/configs" class="text-gray-500 hover:text-gray-700">
|
<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">
|
<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 19l-7-7 7-7"></path>
|
<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>
|
</svg>
|
||||||
</a>
|
</a>
|
||||||
<h1 class="text-2xl font-bold">
|
<div class="text-2xl font-bold flex items-center gap-2" id="config-breadcrumbs">
|
||||||
<span id="config-name">Конфигуратор</span>
|
<a id="breadcrumb-project-code-link" href="/projects" class="text-blue-700 hover:underline">
|
||||||
</h1>
|
<span id="breadcrumb-project-code">—</span>
|
||||||
|
</a>
|
||||||
|
<span class="text-gray-400">-</span>
|
||||||
|
<a id="breadcrumb-project-variant-link" href="/projects" class="text-blue-700 hover:underline">
|
||||||
|
<span id="breadcrumb-project-variant">main</span>
|
||||||
|
</a>
|
||||||
|
<span class="text-gray-400">-</span>
|
||||||
|
<span id="breadcrumb-config-name">Конфигуратор</span>
|
||||||
|
<span class="text-gray-400">-</span>
|
||||||
|
<span id="breadcrumb-config-version">v1</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="save-buttons" class="hidden flex items-center space-x-2">
|
<div id="save-buttons" class="hidden flex items-center space-x-2">
|
||||||
<button id="refresh-prices-btn" onclick="refreshPrices()" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">
|
<button id="refresh-prices-btn" onclick="refreshPrices()" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">
|
||||||
@@ -329,6 +340,67 @@ let configUUID = '{{.ConfigUUID}}';
|
|||||||
let configName = '';
|
let configName = '';
|
||||||
let projectUUID = '';
|
let projectUUID = '';
|
||||||
let projectName = '';
|
let projectName = '';
|
||||||
|
let projectCode = '';
|
||||||
|
let projectVariant = '';
|
||||||
|
let projectIndexLoaded = false;
|
||||||
|
let projectByUUID = {};
|
||||||
|
let projectMainByCode = {};
|
||||||
|
|
||||||
|
async function loadProjectIndex() {
|
||||||
|
if (projectIndexLoaded) return;
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/projects/all');
|
||||||
|
if (!resp.ok) return;
|
||||||
|
const data = await resp.json();
|
||||||
|
const allProjects = Array.isArray(data) ? data : (data.projects || []);
|
||||||
|
projectByUUID = {};
|
||||||
|
projectMainByCode = {};
|
||||||
|
allProjects.forEach(p => {
|
||||||
|
projectByUUID[p.uuid] = p;
|
||||||
|
const code = (p.code || '').trim();
|
||||||
|
const variant = (p.variant || '').trim();
|
||||||
|
if (code && (variant === '' || variant === 'main')) {
|
||||||
|
if (!projectMainByCode[code]) {
|
||||||
|
projectMainByCode[code] = p.uuid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
projectIndexLoaded = true;
|
||||||
|
} catch (e) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateConfigBreadcrumbs() {
|
||||||
|
const codeEl = document.getElementById('breadcrumb-project-code');
|
||||||
|
const variantEl = document.getElementById('breadcrumb-project-variant');
|
||||||
|
const configEl = document.getElementById('breadcrumb-config-name');
|
||||||
|
const versionEl = document.getElementById('breadcrumb-config-version');
|
||||||
|
const projectCodeLinkEl = document.getElementById('breadcrumb-project-code-link');
|
||||||
|
const projectVariantLinkEl = document.getElementById('breadcrumb-project-variant-link');
|
||||||
|
|
||||||
|
let code = 'Без проекта';
|
||||||
|
let variant = 'main';
|
||||||
|
if (projectUUID && projectByUUID[projectUUID]) {
|
||||||
|
code = (projectByUUID[projectUUID].code || '').trim() || 'Без проекта';
|
||||||
|
const rawVariant = (projectByUUID[projectUUID].variant || '').trim();
|
||||||
|
variant = rawVariant === '' ? 'main' : rawVariant;
|
||||||
|
if (projectCodeLinkEl) {
|
||||||
|
const mainUUID = projectMainByCode[code];
|
||||||
|
projectCodeLinkEl.href = mainUUID ? ('/projects/' + mainUUID) : ('/projects/' + projectUUID);
|
||||||
|
}
|
||||||
|
if (projectVariantLinkEl) {
|
||||||
|
projectVariantLinkEl.href = '/projects/' + projectUUID;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (projectCodeLinkEl) projectCodeLinkEl.href = '/projects';
|
||||||
|
if (projectVariantLinkEl) projectVariantLinkEl.href = '/projects';
|
||||||
|
}
|
||||||
|
codeEl.textContent = code;
|
||||||
|
variantEl.textContent = variant;
|
||||||
|
configEl.textContent = configName || 'Конфигурация';
|
||||||
|
versionEl.textContent = 'v1';
|
||||||
|
}
|
||||||
let currentTab = 'base';
|
let currentTab = 'base';
|
||||||
let allComponents = [];
|
let allComponents = [];
|
||||||
let cart = [];
|
let cart = [];
|
||||||
@@ -617,7 +689,8 @@ document.addEventListener('DOMContentLoaded', async function() {
|
|||||||
const config = await resp.json();
|
const config = await resp.json();
|
||||||
configName = config.name;
|
configName = config.name;
|
||||||
projectUUID = config.project_uuid || '';
|
projectUUID = config.project_uuid || '';
|
||||||
document.getElementById('config-name').textContent = config.name;
|
await loadProjectIndex();
|
||||||
|
updateConfigBreadcrumbs();
|
||||||
document.getElementById('save-buttons').classList.remove('hidden');
|
document.getElementById('save-buttons').classList.remove('hidden');
|
||||||
|
|
||||||
// Set server count from config
|
// Set server count from config
|
||||||
|
|||||||
@@ -3,9 +3,10 @@
|
|||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<div class="flex items-center space-x-4">
|
<div class="flex items-center space-x-4">
|
||||||
<a href="/pricelists" class="text-gray-500 hover:text-gray-700">
|
<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">
|
<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 19l-7-7 7-7"></path>
|
<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>
|
</svg>
|
||||||
</a>
|
</a>
|
||||||
<h1 id="page-title" class="text-2xl font-bold text-gray-900">Загрузка...</h1>
|
<h1 id="page-title" class="text-2xl font-bold text-gray-900">Загрузка...</h1>
|
||||||
|
|||||||
@@ -4,23 +4,43 @@
|
|||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div class="flex items-center justify-between gap-3">
|
<div class="flex items-center justify-between gap-3">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<a href="/projects" class="text-gray-500 hover:text-gray-700" title="Назад к проектам">
|
<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">
|
<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 19l-7-7 7-7"></path>
|
<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>
|
</svg>
|
||||||
</a>
|
</a>
|
||||||
<h1 class="text-2xl font-bold" id="project-title">Проект</h1>
|
<div class="text-2xl font-bold flex items-center gap-2">
|
||||||
|
<a id="project-code-link" href="/projects" class="text-blue-700 hover:underline">
|
||||||
|
<span id="project-code">—</span>
|
||||||
|
</a>
|
||||||
|
<span class="text-gray-400">-</span>
|
||||||
|
<div class="relative">
|
||||||
|
<button id="project-variant-button" type="button" class="inline-flex items-center gap-2 text-base font-medium px-3 py-1.5 rounded-lg bg-gray-100 hover:bg-gray-200 border border-gray-200">
|
||||||
|
<span id="project-variant-label">main</span>
|
||||||
|
<svg class="w-4 h-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div id="project-variant-menu" class="absolute left-0 mt-2 min-w-[10rem] rounded-lg border border-gray-200 bg-white shadow-lg hidden z-10">
|
||||||
|
<div id="project-variant-list" class="py-1"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="action-buttons" class="mt-4 grid grid-cols-1 sm:grid-cols-3 gap-3">
|
<div id="action-buttons" class="mt-4 grid grid-cols-1 sm:grid-cols-4 gap-3">
|
||||||
<button onclick="openCreateModal()" class="py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium">
|
<button onclick="openNewVariantModal()" class="py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 font-medium">
|
||||||
|
+ Новый вариант
|
||||||
|
</button>
|
||||||
|
<button onclick="openCreateModal()" class="py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium">
|
||||||
+ Создать новую квоту
|
+ Создать новую квоту
|
||||||
</button>
|
</button>
|
||||||
<button onclick="openImportModal()" class="py-3 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 font-medium">
|
<button onclick="openImportModal()" class="py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 font-medium">
|
||||||
Импорт квоты
|
Импорт квоты
|
||||||
</button>
|
</button>
|
||||||
<button onclick="openProjectSettingsModal()" class="py-3 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>
|
||||||
</div>
|
</div>
|
||||||
@@ -61,6 +81,33 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="new-variant-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-lg 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>
|
||||||
|
<div id="new-variant-code" class="px-3 py-2 bg-gray-50 border rounded text-sm text-gray-700">—</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="new-variant-name" class="block text-sm font-medium text-gray-700 mb-1">Название (необязательно)</label>
|
||||||
|
<input id="new-variant-name" type="text" placeholder="Например: Lenovo"
|
||||||
|
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="new-variant-value" class="block text-sm font-medium text-gray-700 mb-1">Вариант</label>
|
||||||
|
<input id="new-variant-value" type="text" placeholder="Например: Lenovo"
|
||||||
|
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||||
|
<div class="text-xs text-gray-500 mt-1">Оставьте пустым для main нельзя — нужно уникальное значение.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-6 flex justify-end gap-2">
|
||||||
|
<button onclick="closeNewVariantModal()" class="px-4 py-2 text-gray-700 bg-gray-100 rounded hover:bg-gray-200">Отмена</button>
|
||||||
|
<button onclick="createNewVariant()" class="px-4 py-2 text-white bg-purple-600 rounded hover:bg-purple-700">Создать</button>
|
||||||
|
</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="rename-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>
|
||||||
@@ -120,6 +167,16 @@
|
|||||||
<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>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Код проекта</label>
|
||||||
|
<input type="text" id="project-settings-code"
|
||||||
|
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Вариант (необязательно)</label>
|
||||||
|
<input type="text" id="project-settings-variant"
|
||||||
|
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||||
|
</div>
|
||||||
<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="project-settings-name"
|
<input type="text" id="project-settings-name"
|
||||||
@@ -144,6 +201,8 @@ const projectUUID = '{{.ProjectUUID}}';
|
|||||||
let configStatusMode = 'active';
|
let configStatusMode = 'active';
|
||||||
let project = null;
|
let project = null;
|
||||||
let allConfigs = [];
|
let allConfigs = [];
|
||||||
|
let projectVariants = [];
|
||||||
|
let variantMenuInitialized = false;
|
||||||
|
|
||||||
function escapeHtml(text) {
|
function escapeHtml(text) {
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
@@ -157,6 +216,91 @@ function resolveProjectTrackerURL(projectData) {
|
|||||||
return explicitURL;
|
return explicitURL;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatProjectTitle(projectData) {
|
||||||
|
if (!projectData) return 'Проект';
|
||||||
|
const code = (projectData.code || '').trim();
|
||||||
|
const name = (projectData.name || '').trim();
|
||||||
|
const variant = (projectData.variant || '').trim();
|
||||||
|
if (!code) return name || 'Проект';
|
||||||
|
if (variant) {
|
||||||
|
return code + ': (' + variant + ') ' + (name || '');
|
||||||
|
}
|
||||||
|
return code + ': ' + (name || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeVariantLabel(variant) {
|
||||||
|
const trimmed = (variant || '').trim();
|
||||||
|
return trimmed === '' ? 'main' : trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadVariantsForCode(code) {
|
||||||
|
if (!code) return;
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/projects/all');
|
||||||
|
if (!resp.ok) return;
|
||||||
|
const data = await resp.json();
|
||||||
|
const allProjects = Array.isArray(data) ? data : (data.projects || []);
|
||||||
|
projectVariants = allProjects
|
||||||
|
.filter(p => (p.code || '').trim() === code)
|
||||||
|
.map(p => ({uuid: p.uuid, variant: (p.variant || '').trim()}));
|
||||||
|
projectVariants.sort((a, b) => normalizeVariantLabel(a.variant).localeCompare(normalizeVariantLabel(b.variant)));
|
||||||
|
} catch (e) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderVariantSelect() {
|
||||||
|
const list = document.getElementById('project-variant-list');
|
||||||
|
const menu = document.getElementById('project-variant-menu');
|
||||||
|
const button = document.getElementById('project-variant-button');
|
||||||
|
const label = document.getElementById('project-variant-label');
|
||||||
|
const codeLink = document.getElementById('project-code-link');
|
||||||
|
if (!list || !menu || !button || !label) return;
|
||||||
|
list.innerHTML = '';
|
||||||
|
const variants = projectVariants.length ? projectVariants : [{uuid: projectUUID, variant: (project && project.variant) || ''}];
|
||||||
|
let mainUUID = '';
|
||||||
|
variants.forEach(item => {
|
||||||
|
const variantLabel = normalizeVariantLabel(item.variant);
|
||||||
|
if (variantLabel === 'main' && !mainUUID) {
|
||||||
|
mainUUID = item.uuid;
|
||||||
|
}
|
||||||
|
const option = document.createElement('button');
|
||||||
|
option.type = 'button';
|
||||||
|
option.className = 'w-full text-left px-3 py-2 text-sm hover:bg-gray-50';
|
||||||
|
if (item.uuid === projectUUID) {
|
||||||
|
option.className += ' font-semibold text-gray-900';
|
||||||
|
label.textContent = variantLabel;
|
||||||
|
}
|
||||||
|
option.textContent = variantLabel;
|
||||||
|
option.onclick = function() {
|
||||||
|
menu.classList.add('hidden');
|
||||||
|
if (item.uuid && item.uuid !== projectUUID) {
|
||||||
|
window.location.href = '/projects/' + item.uuid;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
list.appendChild(option);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (codeLink) {
|
||||||
|
const targetMain = mainUUID || projectUUID;
|
||||||
|
codeLink.href = '/projects/' + targetMain;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!variantMenuInitialized) {
|
||||||
|
button.onclick = function(e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
menu.classList.toggle('hidden');
|
||||||
|
};
|
||||||
|
document.addEventListener('click', function() {
|
||||||
|
menu.classList.add('hidden');
|
||||||
|
});
|
||||||
|
menu.addEventListener('click', function(e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
});
|
||||||
|
variantMenuInitialized = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function setConfigStatusMode(mode) {
|
function setConfigStatusMode(mode) {
|
||||||
if (mode !== 'active' && mode !== 'archived') return;
|
if (mode !== 'active' && mode !== 'archived') return;
|
||||||
configStatusMode = mode;
|
configStatusMode = mode;
|
||||||
@@ -254,7 +398,9 @@ async function loadProject() {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
project = await resp.json();
|
project = await resp.json();
|
||||||
document.getElementById('project-title').textContent = project.name;
|
document.getElementById('project-code').textContent = project.code || '—';
|
||||||
|
await loadVariantsForCode(project.code || '');
|
||||||
|
renderVariantSelect();
|
||||||
const trackerLink = document.getElementById('tracker-link');
|
const trackerLink = document.getElementById('tracker-link');
|
||||||
if (trackerLink) {
|
if (trackerLink) {
|
||||||
if (project && project.is_system) {
|
if (project && project.is_system) {
|
||||||
@@ -297,6 +443,56 @@ function openCreateModal() {
|
|||||||
document.getElementById('create-name').focus();
|
document.getElementById('create-name').focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openNewVariantModal() {
|
||||||
|
if (!project) return;
|
||||||
|
document.getElementById('new-variant-code').textContent = (project.code || '').trim() || '—';
|
||||||
|
document.getElementById('new-variant-name').value = project.name || '';
|
||||||
|
document.getElementById('new-variant-value').value = '';
|
||||||
|
document.getElementById('new-variant-modal').classList.remove('hidden');
|
||||||
|
document.getElementById('new-variant-modal').classList.add('flex');
|
||||||
|
document.getElementById('new-variant-value').focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeNewVariantModal() {
|
||||||
|
document.getElementById('new-variant-modal').classList.add('hidden');
|
||||||
|
document.getElementById('new-variant-modal').classList.remove('flex');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createNewVariant() {
|
||||||
|
if (!project) return;
|
||||||
|
const code = (project.code || '').trim();
|
||||||
|
const variant = (document.getElementById('new-variant-value').value || '').trim();
|
||||||
|
const nameRaw = (document.getElementById('new-variant-name').value || '').trim();
|
||||||
|
if (!code || !variant) {
|
||||||
|
showToast('Укажите вариант', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const payload = {
|
||||||
|
code: code,
|
||||||
|
variant: variant,
|
||||||
|
name: nameRaw ? nameRaw : null
|
||||||
|
};
|
||||||
|
const resp = await fetch('/api/projects', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
const data = await resp.json().catch(() => ({}));
|
||||||
|
showToast(data.error || 'Ошибка создания варианта', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const created = await resp.json().catch(() => null);
|
||||||
|
closeNewVariantModal();
|
||||||
|
showToast('Вариант создан', 'success');
|
||||||
|
if (created && created.uuid) {
|
||||||
|
window.location.href = '/projects/' + created.uuid;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loadProject();
|
||||||
|
loadConfigs();
|
||||||
|
}
|
||||||
|
|
||||||
function closeCreateModal() {
|
function closeCreateModal() {
|
||||||
document.getElementById('create-modal').classList.add('hidden');
|
document.getElementById('create-modal').classList.add('hidden');
|
||||||
document.getElementById('create-modal').classList.remove('flex');
|
document.getElementById('create-modal').classList.remove('flex');
|
||||||
@@ -429,6 +625,8 @@ function openProjectSettingsModal() {
|
|||||||
alert('Системный проект нельзя редактировать');
|
alert('Системный проект нельзя редактировать');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
document.getElementById('project-settings-code').value = project.code || '';
|
||||||
|
document.getElementById('project-settings-variant').value = project.variant || '';
|
||||||
document.getElementById('project-settings-name').value = project.name || '';
|
document.getElementById('project-settings-name').value = project.name || '';
|
||||||
document.getElementById('project-settings-tracker-url').value = (project.tracker_url || '').trim();
|
document.getElementById('project-settings-tracker-url').value = (project.tracker_url || '').trim();
|
||||||
document.getElementById('project-settings-modal').classList.remove('hidden');
|
document.getElementById('project-settings-modal').classList.remove('hidden');
|
||||||
@@ -442,27 +640,31 @@ function closeProjectSettingsModal() {
|
|||||||
|
|
||||||
async function saveProjectSettings() {
|
async function saveProjectSettings() {
|
||||||
if (!project) return;
|
if (!project) return;
|
||||||
|
const code = document.getElementById('project-settings-code').value.trim();
|
||||||
|
const variant = document.getElementById('project-settings-variant').value.trim();
|
||||||
const name = document.getElementById('project-settings-name').value.trim();
|
const name = document.getElementById('project-settings-name').value.trim();
|
||||||
const trackerURL = document.getElementById('project-settings-tracker-url').value.trim();
|
const trackerURL = document.getElementById('project-settings-tracker-url').value.trim();
|
||||||
if (!name) {
|
if (!code) {
|
||||||
alert('Введите название проекта');
|
alert('Введите код проекта');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const resp = await fetch('/api/projects/' + projectUUID, {
|
const resp = await fetch('/api/projects/' + projectUUID, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: {'Content-Type': 'application/json'},
|
headers: {'Content-Type': 'application/json'},
|
||||||
body: JSON.stringify({name: name, tracker_url: trackerURL})
|
body: JSON.stringify({code: code, variant: variant, name: name, tracker_url: trackerURL})
|
||||||
});
|
});
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
if (resp.status === 409) {
|
if (resp.status === 409) {
|
||||||
alert('Проект с таким названием уже существует');
|
alert('Проект с таким кодом и вариантом уже существует');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
alert('Не удалось сохранить параметры проекта');
|
alert('Не удалось сохранить параметры проекта');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
project = await resp.json();
|
project = await resp.json();
|
||||||
document.getElementById('project-title').textContent = project.name;
|
document.getElementById('project-code').textContent = project.code || '—';
|
||||||
|
await loadVariantsForCode(project.code || '');
|
||||||
|
renderVariantSelect();
|
||||||
const trackerLink = document.getElementById('tracker-link');
|
const trackerLink = document.getElementById('tracker-link');
|
||||||
if (trackerLink) {
|
if (trackerLink) {
|
||||||
const trackerURLResolved = resolveProjectTrackerURL(project);
|
const trackerURLResolved = resolveProjectTrackerURL(project);
|
||||||
@@ -557,6 +759,7 @@ function wildcardMatch(value, pattern) {
|
|||||||
|
|
||||||
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('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(); });
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="max-w-md">
|
<div class="max-w-md">
|
||||||
<input id="projects-search" type="text" placeholder="Поиск проекта по названию"
|
<input id="projects-search" type="text" 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">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -31,11 +31,21 @@
|
|||||||
<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>
|
||||||
|
<label for="create-project-name" class="block text-sm font-medium text-gray-700 mb-1">Название проекта</label>
|
||||||
|
<input id="create-project-name" type="text" placeholder="Например: Инфраструктура для OPS-123"
|
||||||
|
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="create-project-code" class="block text-sm font-medium text-gray-700 mb-1">Код проекта</label>
|
<label for="create-project-code" class="block text-sm font-medium text-gray-700 mb-1">Код проекта</label>
|
||||||
<input id="create-project-code" type="text" placeholder="Например: OPS-123"
|
<input id="create-project-code" type="text" placeholder="Например: OPS-123"
|
||||||
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">
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="create-project-variant" class="block text-sm font-medium text-gray-700 mb-1">Вариант (необязательно)</label>
|
||||||
|
<input id="create-project-variant" type="text" placeholder="Например: Lenovo"
|
||||||
|
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="create-project-tracker-url" class="block text-sm font-medium text-gray-700 mb-1">Ссылка на трекер</label>
|
<label for="create-project-tracker-url" class="block text-sm font-medium text-gray-700 mb-1">Ссылка на трекер</label>
|
||||||
<input id="create-project-tracker-url" type="url" placeholder="https://tracker.yandex.ru/OPS-123"
|
<input id="create-project-tracker-url" type="url" placeholder="https://tracker.yandex.ru/OPS-123"
|
||||||
@@ -59,6 +69,8 @@ let sortField = 'created_at';
|
|||||||
let sortDir = 'desc';
|
let sortDir = 'desc';
|
||||||
let createProjectTrackerManuallyEdited = false;
|
let createProjectTrackerManuallyEdited = false;
|
||||||
let createProjectLastAutoTrackerURL = '';
|
let createProjectLastAutoTrackerURL = '';
|
||||||
|
let variantsByCode = {};
|
||||||
|
let variantsLoaded = false;
|
||||||
|
|
||||||
const trackerBaseURL = 'https://tracker.yandex.ru/';
|
const trackerBaseURL = 'https://tracker.yandex.ru/';
|
||||||
|
|
||||||
@@ -85,6 +97,55 @@ function formatDateTime(value) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeVariant(variant) {
|
||||||
|
const trimmed = (variant || '').trim();
|
||||||
|
return trimmed === '' ? 'main' : trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderVariantChips(code, fallbackVariant, fallbackUUID) {
|
||||||
|
const variants = variantsByCode[code || ''] || [];
|
||||||
|
if (!variants.length) {
|
||||||
|
const single = normalizeVariant(fallbackVariant);
|
||||||
|
const href = fallbackUUID ? ('/projects/' + fallbackUUID) : '/projects';
|
||||||
|
return '<a href="' + href + '" class="inline-flex items-center px-2 py-0.5 text-xs rounded-full bg-gray-100 text-gray-600 hover:bg-gray-200 hover:text-gray-900">' + escapeHtml(single) + '</a>';
|
||||||
|
}
|
||||||
|
return variants.map(v => {
|
||||||
|
const href = v.uuid ? ('/projects/' + v.uuid) : '/projects';
|
||||||
|
return '<a href="' + href + '" class="inline-flex items-center px-2 py-0.5 text-xs rounded-full bg-gray-100 text-gray-700 hover:bg-gray-200 hover:text-gray-900">' + escapeHtml(v.label) + '</a>';
|
||||||
|
}).join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadVariantsIndex() {
|
||||||
|
if (variantsLoaded) return;
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/projects/all');
|
||||||
|
if (!resp.ok) return;
|
||||||
|
const data = await resp.json();
|
||||||
|
const allProjects = Array.isArray(data) ? data : (data.projects || []);
|
||||||
|
variantsByCode = {};
|
||||||
|
allProjects.forEach(p => {
|
||||||
|
const code = (p.code || '').trim();
|
||||||
|
const variant = normalizeVariant(p.variant);
|
||||||
|
if (!variantsByCode[code]) {
|
||||||
|
variantsByCode[code] = [];
|
||||||
|
}
|
||||||
|
if (!variantsByCode[code].some(v => v.label === variant)) {
|
||||||
|
variantsByCode[code].push({label: variant, uuid: p.uuid});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Object.keys(variantsByCode).forEach(code => {
|
||||||
|
variantsByCode[code].sort((a, b) => {
|
||||||
|
if (a.label === 'main') return -1;
|
||||||
|
if (b.label === 'main') return 1;
|
||||||
|
return a.label.localeCompare(b.label);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
variantsLoaded = true;
|
||||||
|
} catch (e) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function toggleSort(field) {
|
function toggleSort(field) {
|
||||||
if (sortField === field) {
|
if (sortField === field) {
|
||||||
sortDir = sortDir === 'asc' ? 'desc' : 'asc';
|
sortDir = sortDir === 'asc' ? 'desc' : 'asc';
|
||||||
@@ -132,10 +193,33 @@ async function loadProjects() {
|
|||||||
}
|
}
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
rows = data.projects || [];
|
rows = data.projects || [];
|
||||||
|
if (Array.isArray(rows) && rows.length) {
|
||||||
|
const byCode = {};
|
||||||
|
rows.forEach(p => {
|
||||||
|
const codeKey = (p.code || '').trim();
|
||||||
|
if (!codeKey) {
|
||||||
|
const fallbackKey = p.uuid || Math.random().toString(36);
|
||||||
|
byCode[fallbackKey] = p;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const variant = (p.variant || '').trim();
|
||||||
|
if (!byCode[codeKey]) {
|
||||||
|
byCode[codeKey] = p;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const current = byCode[codeKey];
|
||||||
|
const currentVariant = (current.variant || '').trim();
|
||||||
|
if (currentVariant !== '' && variant === '') {
|
||||||
|
byCode[codeKey] = p;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
rows = Object.values(byCode);
|
||||||
|
}
|
||||||
total = data.total || 0;
|
total = data.total || 0;
|
||||||
totalPages = data.total_pages || 0;
|
totalPages = data.total_pages || 0;
|
||||||
page = data.page || currentPage;
|
page = data.page || currentPage;
|
||||||
currentPage = page;
|
currentPage = page;
|
||||||
|
await loadVariantsIndex();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
root.innerHTML = '<div class="text-red-600">Ошибка загрузки проектов: ' + escapeHtml(String(e.message || e)) + '</div>';
|
root.innerHTML = '<div class="text-red-600">Ошибка загрузки проектов: ' + escapeHtml(String(e.message || e)) + '</div>';
|
||||||
return;
|
return;
|
||||||
@@ -144,27 +228,22 @@ async function loadProjects() {
|
|||||||
let html = '<div class="overflow-x-auto"><table class="w-full">';
|
let html = '<div class="overflow-x-auto"><table class="w-full">';
|
||||||
html += '<thead class="bg-gray-50">';
|
html += '<thead class="bg-gray-50">';
|
||||||
html += '<tr>';
|
html += '<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">';
|
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">';
|
||||||
html += '<button type="button" onclick="toggleSort(\'name\')" class="inline-flex items-center gap-1 hover:text-gray-700">Название проекта';
|
html += '<button type="button" onclick="toggleSort(\'name\')" class="inline-flex items-center gap-1 hover:text-gray-700">Название';
|
||||||
if (sortField === 'name') {
|
if (sortField === 'name') {
|
||||||
html += sortDir === 'asc' ? ' <span>↑</span>' : ' <span>↓</span>';
|
html += sortDir === 'asc' ? ' <span>↑</span>' : ' <span>↓</span>';
|
||||||
}
|
}
|
||||||
html += '</button></th>';
|
html += '</button></th>';
|
||||||
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Автор</th>';
|
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Создан @ автор</th>';
|
||||||
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">';
|
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Изменен @ кто</th>';
|
||||||
html += '<button type="button" onclick="toggleSort(\'created_at\')" class="inline-flex items-center gap-1 hover:text-gray-700">Создан';
|
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Варианты</th>';
|
||||||
if (sortField === 'created_at') {
|
|
||||||
html += sortDir === 'asc' ? ' <span>↑</span>' : ' <span>↓</span>';
|
|
||||||
}
|
|
||||||
html += '</button></th>';
|
|
||||||
html += '<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Кол-во квот</th>';
|
|
||||||
html += '<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Сумма</th>';
|
|
||||||
html += '<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Действия</th>';
|
html += '<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Действия</th>';
|
||||||
html += '</tr>';
|
html += '</tr>';
|
||||||
html += '<tr>';
|
html += '<tr>';
|
||||||
html += '<th class="px-4 py-2"></th>';
|
html += '<th class="px-4 py-2"></th>';
|
||||||
html += '<th class="px-4 py-2"><input id="projects-author-filter" type="text" value="' + escapeHtml(authorSearch) + '" placeholder="Фильтр автора" class="w-full px-2 py-1 border rounded text-xs focus:ring-1 focus:ring-blue-500 focus:border-blue-500"></th>';
|
|
||||||
html += '<th class="px-4 py-2"></th>';
|
html += '<th class="px-4 py-2"></th>';
|
||||||
|
html += '<th class="px-4 py-2"><input id="projects-author-filter" type="text" value="' + escapeHtml(authorSearch) + '" placeholder="Фильтр автора" class="w-full px-2 py-1 border rounded text-xs focus:ring-1 focus:ring-blue-500 focus:border-blue-500"></th>';
|
||||||
html += '<th class="px-4 py-2"></th>';
|
html += '<th class="px-4 py-2"></th>';
|
||||||
html += '<th class="px-4 py-2"></th>';
|
html += '<th class="px-4 py-2"></th>';
|
||||||
html += '<th class="px-4 py-2"></th>';
|
html += '<th class="px-4 py-2"></th>';
|
||||||
@@ -177,19 +256,26 @@ async function loadProjects() {
|
|||||||
|
|
||||||
rows.forEach(p => {
|
rows.forEach(p => {
|
||||||
html += '<tr class="hover:bg-gray-50">';
|
html += '<tr class="hover:bg-gray-50">';
|
||||||
html += '<td class="px-4 py-3 text-sm font-medium"><a class="text-blue-600 hover:underline" href="/projects/' + p.uuid + '">' + escapeHtml(p.name) + '</a></td>';
|
const displayName = p.name || '';
|
||||||
html += '<td class="px-4 py-3 text-sm text-gray-600">' + escapeHtml(p.owner_username || '—') + '</td>';
|
const createdBy = p.owner_username || '—';
|
||||||
html += '<td class="px-4 py-3 text-sm text-gray-600">' + escapeHtml(formatDateTime(p.created_at)) + '</td>';
|
const updatedBy = '—';
|
||||||
html += '<td class="px-4 py-3 text-sm text-right text-gray-700">' + (p.config_count || 0) + '</td>';
|
const createdLabel = formatDateTime(p.created_at) + ' @ ' + createdBy;
|
||||||
html += '<td class="px-4 py-3 text-sm text-right text-gray-700">' + formatMoney(p.total) + '</td>';
|
const updatedLabel = formatDateTime(p.updated_at) + ' @ ' + updatedBy;
|
||||||
|
const variantChips = renderVariantChips(p.code, p.variant, p.uuid);
|
||||||
|
html += '<td class="px-4 py-3 text-sm font-medium"><a class="text-blue-600 hover:underline" href="/projects/' + p.uuid + '">' + escapeHtml(p.code || '—') + '</a></td>';
|
||||||
|
html += '<td class="px-4 py-3 text-sm text-gray-700">' + escapeHtml(displayName) + '</td>';
|
||||||
|
html += '<td class="px-4 py-3 text-sm text-gray-600">' + escapeHtml(createdLabel) + '</td>';
|
||||||
|
html += '<td class="px-4 py-3 text-sm text-gray-600">' + escapeHtml(updatedLabel) + '</td>';
|
||||||
|
html += '<td class="px-4 py-3 text-sm">' + variantChips + '</td>';
|
||||||
html += '<td class="px-4 py-3 text-sm text-right"><div class="inline-flex items-center gap-2">';
|
html += '<td class="px-4 py-3 text-sm text-right"><div class="inline-flex items-center gap-2">';
|
||||||
|
|
||||||
if (p.is_active) {
|
if (p.is_active) {
|
||||||
html += '<button onclick="copyProject(\'' + p.uuid + '\', \'' + escapeHtml(p.name).replace(/'/g, "\\'") + '\')" class="text-green-700 hover:text-green-900" title="Копировать">';
|
const safeName = escapeHtml(displayName).replace(/'/g, "\\'");
|
||||||
|
html += '<button onclick="copyProject(' + JSON.stringify(p.uuid) + ', ' + JSON.stringify(displayName) + ')" class="text-green-700 hover:text-green-900" 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>';
|
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>';
|
||||||
html += '</button>';
|
html += '</button>';
|
||||||
|
|
||||||
html += '<button onclick="renameProject(\'' + p.uuid + '\', \'' + escapeHtml(p.name).replace(/'/g, "\\'") + '\')" class="text-blue-700 hover:text-blue-900" title="Переименовать">';
|
html += '<button onclick="renameProject(' + JSON.stringify(p.uuid) + ', ' + JSON.stringify(displayName) + ')" class="text-blue-700 hover:text-blue-900" 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>';
|
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>';
|
||||||
html += '</button>';
|
html += '</button>';
|
||||||
|
|
||||||
@@ -251,15 +337,19 @@ function buildTrackerURLFromProjectCode(projectCode) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openCreateProjectModal() {
|
function openCreateProjectModal() {
|
||||||
|
const nameInput = document.getElementById('create-project-name');
|
||||||
const codeInput = document.getElementById('create-project-code');
|
const codeInput = document.getElementById('create-project-code');
|
||||||
|
const variantInput = document.getElementById('create-project-variant');
|
||||||
const trackerInput = document.getElementById('create-project-tracker-url');
|
const trackerInput = document.getElementById('create-project-tracker-url');
|
||||||
|
nameInput.value = '';
|
||||||
codeInput.value = '';
|
codeInput.value = '';
|
||||||
|
variantInput.value = '';
|
||||||
trackerInput.value = '';
|
trackerInput.value = '';
|
||||||
createProjectTrackerManuallyEdited = false;
|
createProjectTrackerManuallyEdited = false;
|
||||||
createProjectLastAutoTrackerURL = '';
|
createProjectLastAutoTrackerURL = '';
|
||||||
document.getElementById('create-project-modal').classList.remove('hidden');
|
document.getElementById('create-project-modal').classList.remove('hidden');
|
||||||
document.getElementById('create-project-modal').classList.add('flex');
|
document.getElementById('create-project-modal').classList.add('flex');
|
||||||
codeInput.focus();
|
nameInput.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeCreateProjectModal() {
|
function closeCreateProjectModal() {
|
||||||
@@ -278,10 +368,14 @@ function updateCreateProjectTrackerURL() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function createProject() {
|
async function createProject() {
|
||||||
|
const nameInput = document.getElementById('create-project-name');
|
||||||
const codeInput = document.getElementById('create-project-code');
|
const codeInput = document.getElementById('create-project-code');
|
||||||
|
const variantInput = document.getElementById('create-project-variant');
|
||||||
const trackerInput = document.getElementById('create-project-tracker-url');
|
const trackerInput = document.getElementById('create-project-tracker-url');
|
||||||
const name = (codeInput.value || '').trim();
|
const name = (nameInput.value || '').trim();
|
||||||
if (!name) {
|
const code = (codeInput.value || '').trim();
|
||||||
|
const variant = (variantInput.value || '').trim();
|
||||||
|
if (!code) {
|
||||||
alert('Введите код проекта');
|
alert('Введите код проекта');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -290,12 +384,14 @@ async function createProject() {
|
|||||||
headers: {'Content-Type': 'application/json'},
|
headers: {'Content-Type': 'application/json'},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
name: name,
|
name: name,
|
||||||
|
code: code,
|
||||||
|
variant: variant,
|
||||||
tracker_url: (trackerInput.value || '').trim()
|
tracker_url: (trackerInput.value || '').trim()
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
if (resp.status === 409) {
|
if (resp.status === 409) {
|
||||||
alert('Проект с таким названием уже существует');
|
alert('Проект с таким кодом и вариантом уже существует');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
alert('Не удалось создать проект');
|
alert('Не удалось создать проект');
|
||||||
@@ -361,15 +457,18 @@ async function addConfigToProject(projectUUID) {
|
|||||||
async function copyProject(projectUUID, projectName) {
|
async function copyProject(projectUUID, projectName) {
|
||||||
const newName = prompt('Название копии проекта', projectName + ' (копия)');
|
const newName = prompt('Название копии проекта', projectName + ' (копия)');
|
||||||
if (!newName || !newName.trim()) return;
|
if (!newName || !newName.trim()) return;
|
||||||
|
const newCode = prompt('Код проекта', '');
|
||||||
|
if (!newCode || !newCode.trim()) return;
|
||||||
|
const newVariant = prompt('Вариант (необязательно)', '');
|
||||||
|
|
||||||
const createResp = await fetch('/api/projects', {
|
const createResp = await fetch('/api/projects', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {'Content-Type': 'application/json'},
|
headers: {'Content-Type': 'application/json'},
|
||||||
body: JSON.stringify({name: newName.trim()})
|
body: JSON.stringify({name: newName.trim(), code: newCode.trim(), variant: (newVariant || '').trim()})
|
||||||
});
|
});
|
||||||
if (!createResp.ok) {
|
if (!createResp.ok) {
|
||||||
if (createResp.status === 409) {
|
if (createResp.status === 409) {
|
||||||
alert('Проект с таким названием уже существует');
|
alert('Проект с таким кодом и вариантом уже существует');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
alert('Не удалось создать копию проекта');
|
alert('Не удалось создать копию проекта');
|
||||||
@@ -410,6 +509,13 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
updateCreateProjectTrackerURL();
|
updateCreateProjectTrackerURL();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.getElementById('create-project-name').addEventListener('keydown', function(e) {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
createProject();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
document.getElementById('create-project-tracker-url').addEventListener('input', function(e) {
|
document.getElementById('create-project-tracker-url').addEventListener('input', function(e) {
|
||||||
createProjectTrackerManuallyEdited = (e.target.value || '').trim() !== createProjectLastAutoTrackerURL;
|
createProjectTrackerManuallyEdited = (e.target.value || '').trim() !== createProjectLastAutoTrackerURL;
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user