diff --git a/cmd/qfs/main.go b/cmd/qfs/main.go index f7156e2..0fe1504 100644 --- a/cmd/qfs/main.go +++ b/cmd/qfs/main.go @@ -1428,15 +1428,17 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect // Return simplified list of all projects (UUID + Name only) type ProjectSimple struct { - UUID string `json:"uuid"` - Name string `json:"name"` + UUID string `json:"uuid"` + Name string `json:"name"` + IsActive bool `json:"is_active"` } simplified := make([]ProjectSimple, 0, len(allProjects)) for _, p := range allProjects { simplified = append(simplified, ProjectSimple{ - UUID: p.UUID, - Name: p.Name, + UUID: p.UUID, + Name: p.Name, + IsActive: p.IsActive, }) } @@ -1455,7 +1457,12 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect } project, err := projectService.Create(dbUsername, &req) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + switch { + case errors.Is(err, services.ErrProjectNameExists): + c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) + default: + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + } return } c.JSON(http.StatusCreated, project) @@ -1490,6 +1497,8 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect project, err := projectService.Update(c.Param("uuid"), dbUsername, &req) if err != nil { switch { + case errors.Is(err, services.ErrProjectNameExists): + c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) case errors.Is(err, services.ErrProjectNotFound): c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) case errors.Is(err, services.ErrProjectForbidden): diff --git a/internal/handlers/web.go b/internal/handlers/web.go index 120bd63..46af6ba 100644 --- a/internal/handlers/web.go +++ b/internal/handlers/web.go @@ -147,8 +147,8 @@ func (h *WebHandler) render(c *gin.Context, name string, data gin.H) { } func (h *WebHandler) Index(c *gin.Context) { - // Redirect to configs page - configurator is accessed via /configurator?uuid=... - c.Redirect(302, "/configs") + // Redirect to projects page - configurator is accessed via /configurator?uuid=... + c.Redirect(302, "/projects") } func (h *WebHandler) Configurator(c *gin.Context) { diff --git a/internal/localdb/localdb.go b/internal/localdb/localdb.go index 8972a95..752a93b 100644 --- a/internal/localdb/localdb.go +++ b/internal/localdb/localdb.go @@ -11,8 +11,8 @@ import ( "strings" "time" - "git.mchus.pro/mchus/quoteforge/internal/appstate" "git.mchus.pro/mchus/quoteforge/internal/appmeta" + "git.mchus.pro/mchus/quoteforge/internal/appstate" "github.com/glebarez/sqlite" mysqlDriver "github.com/go-sql-driver/mysql" uuidpkg "github.com/google/uuid" @@ -434,18 +434,36 @@ func (l *LocalDB) ListConfigurationsWithFilters(status string, search string, of query := l.db.Model(&LocalConfiguration{}) switch status { case "active": - query = query.Where("is_active = ?", true) + query = query.Where("local_configurations.is_active = ?", true) case "archived": - query = query.Where("is_active = ?", false) + query = query.Where("local_configurations.is_active = ?", false) case "all", "": // no-op default: - query = query.Where("is_active = ?", true) + query = query.Where("local_configurations.is_active = ?", true) } search = strings.TrimSpace(search) if search != "" { - query = query.Where("LOWER(name) LIKE ?", "%"+strings.ToLower(search)+"%") + needle := "%" + strings.ToLower(search) + "%" + hasProjectsTable := l.db.Migrator().HasTable(&LocalProject{}) + hasServerModel := l.db.Migrator().HasColumn(&LocalConfiguration{}, "server_model") + + conditions := []string{"LOWER(local_configurations.name) LIKE ?"} + args := []interface{}{needle} + + if hasProjectsTable { + query = query.Joins("LEFT JOIN local_projects lp ON lp.uuid = local_configurations.project_uuid") + conditions = append(conditions, "LOWER(COALESCE(lp.name, '')) LIKE ?") + args = append(args, needle) + } + + if hasServerModel { + conditions = append(conditions, "LOWER(COALESCE(local_configurations.server_model, '')) LIKE ?") + args = append(args, needle) + } + + query = query.Where(strings.Join(conditions, " OR "), args...) } var total int64 @@ -454,7 +472,7 @@ func (l *LocalDB) ListConfigurationsWithFilters(status string, search string, of } var configs []LocalConfiguration - if err := query.Order("created_at DESC").Offset(offset).Limit(limit).Find(&configs).Error; err != nil { + if err := query.Order("local_configurations.created_at DESC").Offset(offset).Limit(limit).Find(&configs).Error; err != nil { return nil, 0, err } return configs, total, nil diff --git a/internal/services/project.go b/internal/services/project.go index 6362ea0..adbe7d5 100644 --- a/internal/services/project.go +++ b/internal/services/project.go @@ -16,8 +16,9 @@ import ( ) var ( - ErrProjectNotFound = errors.New("project not found") - ErrProjectForbidden = errors.New("access to project forbidden") + ErrProjectNotFound = errors.New("project not found") + ErrProjectForbidden = errors.New("access to project forbidden") + ErrProjectNameExists = errors.New("project name already exists") ) type ProjectService struct { @@ -49,6 +50,9 @@ func (s *ProjectService) Create(ownerUsername string, req *CreateProjectRequest) if name == "" { return nil, fmt.Errorf("project name is required") } + if err := s.ensureUniqueProjectName("", name); err != nil { + return nil, err + } now := time.Now() localProject := &localdb.LocalProject{ @@ -81,6 +85,9 @@ func (s *ProjectService) Update(projectUUID, ownerUsername string, req *UpdatePr if name == "" { return nil, fmt.Errorf("project name is required") } + if err := s.ensureUniqueProjectName(projectUUID, name); err != nil { + return nil, err + } localProject.Name = name if req.TrackerURL != nil { @@ -99,6 +106,32 @@ func (s *ProjectService) Update(projectUUID, ownerUsername string, req *UpdatePr return localdb.LocalToProject(localProject), nil } +func (s *ProjectService) ensureUniqueProjectName(excludeUUID, name string) error { + normalized := normalizeProjectName(name) + if normalized == "" { + return fmt.Errorf("project name is required") + } + + projects, err := s.localDB.GetAllProjects(true) + if err != nil { + return err + } + for i := range projects { + project := projects[i] + if excludeUUID != "" && project.UUID == excludeUUID { + continue + } + if normalizeProjectName(project.Name) == normalized { + return ErrProjectNameExists + } + } + return nil +} + +func normalizeProjectName(name string) string { + return strings.ToLower(strings.TrimSpace(name)) +} + func (s *ProjectService) Archive(projectUUID, ownerUsername string) error { return s.setProjectActive(projectUUID, ownerUsername, false) } diff --git a/web/templates/configs.html b/web/templates/configs.html index b7b557e..d67533b 100644 --- a/web/templates/configs.html +++ b/web/templates/configs.html @@ -448,9 +448,13 @@ async function createConfig() { let projectUUID = ''; if (projectName) { - const existingProject = projectsCache.find(p => p.is_active && p.name.toLowerCase() === projectName.toLowerCase()); - if (existingProject) { - projectUUID = existingProject.uuid; + const matchedProject = projectsCache.find(p => p.name.toLowerCase() === projectName.toLowerCase()); + if (matchedProject) { + if (!matchedProject.is_active) { + alert('Проект с таким названием находится в архиве. Восстановите его или выберите другой.'); + return; + } + projectUUID = matchedProject.uuid; } else { pendingCreateConfigName = name; pendingCreateProjectName = projectName; @@ -529,9 +533,13 @@ async function confirmMoveProject() { let projectUUID = ''; if (projectName) { - const existingProject = projectsCache.find(p => p.is_active && p.name.toLowerCase() === projectName.toLowerCase()); - if (existingProject) { - projectUUID = existingProject.uuid; + const matchedProject = projectsCache.find(p => p.name.toLowerCase() === projectName.toLowerCase()); + if (matchedProject) { + if (!matchedProject.is_active) { + alert('Проект с таким названием находится в архиве. Восстановите его или выберите другой.'); + return; + } + projectUUID = matchedProject.uuid; } else { pendingMoveConfigUUID = uuid; pendingMoveProjectName = projectName; @@ -587,6 +595,10 @@ async function confirmCreateProjectOnMove() { body: JSON.stringify({ name: projectName }) }); if (!createResp.ok) { + if (createResp.status === 409) { + alert('Проект с таким названием уже существует'); + return; + } const err = await createResp.json(); alert('Не удалось создать проект: ' + (err.error || 'ошибка')); return; @@ -623,6 +635,10 @@ async function confirmCreateProjectOnMove() { body: JSON.stringify({ name: projectName }) }); if (!createResp.ok) { + if (createResp.status === 409) { + alert('Проект с таким названием уже существует'); + return; + } const err = await createResp.json(); alert('Не удалось создать проект: ' + (err.error || 'ошибка')); return; diff --git a/web/templates/project_detail.html b/web/templates/project_detail.html index 744939a..da53c88 100644 --- a/web/templates/project_detail.html +++ b/web/templates/project_detail.html @@ -13,13 +13,16 @@ -
+
+
@@ -113,6 +116,29 @@
+ +