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 {
|
||||
p := projects[i]
|
||||
existingProjects[projectKey(p.OwnerUsername, p.Name)] = &p
|
||||
existingProjects[projectKey(p.OwnerUsername, derefString(p.Name))] = &p
|
||||
}
|
||||
}
|
||||
|
||||
@@ -253,12 +253,13 @@ func executePlan(db *gorm.DB, actions []migrationAction, existingProjects map[st
|
||||
|
||||
for _, action := range actions {
|
||||
key := projectKey(action.OwnerUsername, action.TargetProjectName)
|
||||
project := projectCache[key]
|
||||
project := projectCache[key]
|
||||
if project == nil {
|
||||
project = &models.Project{
|
||||
UUID: uuid.NewString(),
|
||||
OwnerUsername: action.OwnerUsername,
|
||||
Name: action.TargetProjectName,
|
||||
Code: action.TargetProjectName,
|
||||
Name: ptrString(action.TargetProjectName),
|
||||
IsActive: true,
|
||||
IsSystem: false,
|
||||
}
|
||||
@@ -268,7 +269,7 @@ func executePlan(db *gorm.DB, actions []migrationAction, existingProjects map[st
|
||||
projectCache[key] = project
|
||||
} else if !project.IsActive {
|
||||
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
|
||||
}
|
||||
@@ -294,3 +295,14 @@ func setKeys(set map[string]struct{}) []string {
|
||||
func projectKey(owner, name string) string {
|
||||
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() {
|
||||
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)")
|
||||
resetLocalDB := flag.Bool("reset-localdb", false, "reset local SQLite data on startup (keeps connection settings)")
|
||||
migrate := flag.Bool("migrate", false, "run database migrations")
|
||||
version := flag.Bool("version", false, "show version information")
|
||||
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)
|
||||
local, err := localdb.New(resolvedLocalDBPath)
|
||||
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) {
|
||||
if cfg.Server.Host == "" {
|
||||
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 {
|
||||
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
|
||||
}
|
||||
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]
|
||||
right := filtered[j]
|
||||
if sortField == "name" {
|
||||
leftName := strings.ToLower(strings.TrimSpace(left.Name))
|
||||
rightName := strings.ToLower(strings.TrimSpace(right.Name))
|
||||
leftName := strings.ToLower(strings.TrimSpace(derefString(left.Name)))
|
||||
rightName := strings.ToLower(strings.TrimSpace(derefString(right.Name)))
|
||||
if leftName == rightName {
|
||||
if sortDir == "asc" {
|
||||
return left.CreatedAt.Before(right.CreatedAt)
|
||||
@@ -1333,8 +1369,8 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
||||
return leftName > rightName
|
||||
}
|
||||
if left.CreatedAt.Equal(right.CreatedAt) {
|
||||
leftName := strings.ToLower(strings.TrimSpace(left.Name))
|
||||
rightName := strings.ToLower(strings.TrimSpace(right.Name))
|
||||
leftName := strings.ToLower(strings.TrimSpace(derefString(left.Name)))
|
||||
rightName := strings.ToLower(strings.TrimSpace(derefString(right.Name)))
|
||||
if sortDir == "asc" {
|
||||
return leftName < rightName
|
||||
}
|
||||
@@ -1393,6 +1429,8 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
||||
"id": p.ID,
|
||||
"uuid": p.UUID,
|
||||
"owner_username": p.OwnerUsername,
|
||||
"code": p.Code,
|
||||
"variant": p.Variant,
|
||||
"name": p.Name,
|
||||
"tracker_url": p.TrackerURL,
|
||||
"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)
|
||||
type ProjectSimple struct {
|
||||
UUID string `json:"uuid"`
|
||||
Code string `json:"code"`
|
||||
Variant string `json:"variant"`
|
||||
Name string `json:"name"`
|
||||
IsActive bool `json:"is_active"`
|
||||
}
|
||||
@@ -1437,7 +1477,9 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
||||
for _, p := range allProjects {
|
||||
simplified = append(simplified, ProjectSimple{
|
||||
UUID: p.UUID,
|
||||
Name: p.Name,
|
||||
Code: p.Code,
|
||||
Variant: p.Variant,
|
||||
Name: derefString(p.Name),
|
||||
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()})
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Name) == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "project name is required"})
|
||||
if strings.TrimSpace(req.Code) == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "project code is required"})
|
||||
return
|
||||
}
|
||||
project, err := projectService.Create(dbUsername, &req)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, services.ErrProjectNameExists):
|
||||
case errors.Is(err, services.ErrProjectCodeExists):
|
||||
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
||||
default:
|
||||
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()})
|
||||
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)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, services.ErrProjectNameExists):
|
||||
case errors.Is(err, services.ErrProjectCodeExists):
|
||||
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
||||
case errors.Is(err, services.ErrProjectNotFound):
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
|
||||
@@ -149,7 +149,7 @@ func TestProjectArchiveHidesConfigsAndCloneIntoProject(t *testing.T) {
|
||||
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")
|
||||
createProjectRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(createProjectRec, createProjectReq)
|
||||
@@ -243,7 +243,7 @@ func TestConfigMoveToProjectEndpoint(t *testing.T) {
|
||||
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")
|
||||
createProjectRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(createProjectRec, createProjectReq)
|
||||
|
||||
@@ -66,7 +66,7 @@ func (h *ExportHandler) ExportCSV(c *gin.Context) {
|
||||
// Try to load project name from database
|
||||
username := middleware.GetUsername(c)
|
||||
if project, err := h.projectService.GetByUUID(req.ProjectUUID, username); err == nil && project != nil {
|
||||
projectName = project.Name
|
||||
projectName = derefString(project.Name)
|
||||
}
|
||||
}
|
||||
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 {
|
||||
items := make([]services.ExportItem, len(req.Items))
|
||||
var total float64
|
||||
@@ -171,7 +178,7 @@ func (h *ExportHandler) ExportConfigCSV(c *gin.Context) {
|
||||
projectName := config.Name // fallback: use config name if no project
|
||||
if config.ProjectUUID != nil && *config.ProjectUUID != "" {
|
||||
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{
|
||||
UUID: project.UUID,
|
||||
OwnerUsername: project.OwnerUsername,
|
||||
Code: project.Code,
|
||||
Variant: project.Variant,
|
||||
Name: project.Name,
|
||||
TrackerURL: project.TrackerURL,
|
||||
IsActive: project.IsActive,
|
||||
@@ -125,6 +127,8 @@ func LocalToProject(local *LocalProject) *models.Project {
|
||||
project := &models.Project{
|
||||
UUID: local.UUID,
|
||||
OwnerUsername: local.OwnerUsername,
|
||||
Code: local.Code,
|
||||
Variant: local.Variant,
|
||||
Name: local.Name,
|
||||
TrackerURL: local.TrackerURL,
|
||||
IsActive: local.IsActive,
|
||||
|
||||
@@ -42,6 +42,49 @@ type LocalDB struct {
|
||||
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
|
||||
func New(dbPath string) (*LocalDB, error) {
|
||||
// Ensure directory exists
|
||||
@@ -65,10 +108,31 @@ func New(dbPath string) (*LocalDB, error) {
|
||||
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
|
||||
if err := db.AutoMigrate(
|
||||
&ConnectionSettings{},
|
||||
&LocalProject{},
|
||||
&LocalConfiguration{},
|
||||
&LocalConfigurationVersion{},
|
||||
&LocalPricelist{},
|
||||
@@ -93,6 +157,38 @@ func New(dbPath string) (*LocalDB, error) {
|
||||
}, 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
|
||||
func (l *LocalDB) HasSettings() bool {
|
||||
var count int64
|
||||
@@ -267,7 +363,8 @@ func (l *LocalDB) EnsureDefaultProject(ownerUsername string) (*LocalProject, err
|
||||
project = &LocalProject{
|
||||
UUID: uuidpkg.NewString(),
|
||||
OwnerUsername: "",
|
||||
Name: "Без проекта",
|
||||
Code: "Без проекта",
|
||||
Name: ptrString("Без проекта"),
|
||||
IsActive: true,
|
||||
IsSystem: true,
|
||||
CreatedAt: now,
|
||||
@@ -295,7 +392,8 @@ func (l *LocalDB) ConsolidateSystemProjects() (int64, error) {
|
||||
canonical = LocalProject{
|
||||
UUID: uuidpkg.NewString(),
|
||||
OwnerUsername: "",
|
||||
Name: "Без проекта",
|
||||
Code: "Без проекта",
|
||||
Name: ptrString("Без проекта"),
|
||||
IsActive: true,
|
||||
IsSystem: true,
|
||||
CreatedAt: now,
|
||||
@@ -376,6 +474,10 @@ WHERE (
|
||||
return tx.RowsAffected, tx.Error
|
||||
}
|
||||
|
||||
func ptrString(value string) *string {
|
||||
return &value
|
||||
}
|
||||
|
||||
// BackfillConfigurationProjects ensures every configuration has project_uuid set.
|
||||
// If missing, it assigns system project "Без проекта" for configuration owner.
|
||||
func (l *LocalDB) BackfillConfigurationProjects(defaultOwner string) error {
|
||||
|
||||
@@ -51,8 +51,8 @@ func TestRunLocalMigrationsBackfillsDefaultProject(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("get system project: %v", err)
|
||||
}
|
||||
if project.Name != "Без проекта" {
|
||||
t.Fatalf("expected system project name, got %q", project.Name)
|
||||
if project.Name == nil || *project.Name != "Без проекта" {
|
||||
t.Fatalf("expected system project name, got %v", project.Name)
|
||||
}
|
||||
if !project.IsSystem {
|
||||
t.Fatalf("expected system project flag")
|
||||
|
||||
@@ -88,6 +88,21 @@ var localMigrations = []localMigration{
|
||||
name: "Add support_code to local_configurations",
|
||||
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 {
|
||||
@@ -224,7 +239,8 @@ func ensureDefaultProjectTx(tx *gorm.DB, ownerUsername string) (*LocalProject, e
|
||||
project = LocalProject{
|
||||
UUID: uuid.NewString(),
|
||||
OwnerUsername: ownerUsername,
|
||||
Name: "Без проекта",
|
||||
Code: "Без проекта",
|
||||
Name: ptrString("Без проекта"),
|
||||
IsActive: true,
|
||||
IsSystem: true,
|
||||
CreatedAt: now,
|
||||
@@ -238,6 +254,139 @@ func ensureDefaultProjectTx(tx *gorm.DB, ownerUsername string) (*LocalProject, e
|
||||
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 {
|
||||
var latest LocalPricelist
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
func fixLocalPricelistIndexes(tx *gorm.DB) error {
|
||||
type indexRow struct {
|
||||
Name string `gorm:"column:name"`
|
||||
|
||||
@@ -123,7 +123,9 @@ type LocalProject struct {
|
||||
UUID string `gorm:"uniqueIndex;not null" json:"uuid"`
|
||||
ServerID *uint `json:"server_id,omitempty"`
|
||||
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"`
|
||||
IsActive bool `gorm:"default:true;index" json:"is_active"`
|
||||
IsSystem bool `gorm:"default:false;index" json:"is_system"`
|
||||
|
||||
@@ -6,7 +6,9 @@ type Project struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
UUID string `gorm:"size:36;uniqueIndex;not null" json:"uuid"`
|
||||
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"`
|
||||
IsActive bool `gorm:"default:true;index" json:"is_active"`
|
||||
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"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{
|
||||
"owner_username",
|
||||
"code",
|
||||
"variant",
|
||||
"name",
|
||||
"tracker_url",
|
||||
"is_active",
|
||||
|
||||
@@ -191,7 +191,8 @@ func TestUpdateNoAuthKeepsProjectWhenProjectUUIDOmitted(t *testing.T) {
|
||||
project := &localdb.LocalProject{
|
||||
UUID: "project-keep",
|
||||
OwnerUsername: "tester",
|
||||
Name: "Keep Project",
|
||||
Code: "TEST-KEEP",
|
||||
Name: ptrString("Keep Project"),
|
||||
IsActive: true,
|
||||
CreatedAt: 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) {
|
||||
t.Helper()
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ import (
|
||||
var (
|
||||
ErrProjectNotFound = errors.New("project not found")
|
||||
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 {
|
||||
@@ -30,12 +30,16 @@ func NewProjectService(localDB *localdb.LocalDB) *ProjectService {
|
||||
}
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
@@ -46,11 +50,19 @@ type ProjectConfigurationsResult struct {
|
||||
}
|
||||
|
||||
func (s *ProjectService) Create(ownerUsername string, req *CreateProjectRequest) (*models.Project, error) {
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if name == "" {
|
||||
return nil, fmt.Errorf("project name is required")
|
||||
var namePtr *string
|
||||
if req.Name != nil {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -58,8 +70,10 @@ func (s *ProjectService) Create(ownerUsername string, req *CreateProjectRequest)
|
||||
localProject := &localdb.LocalProject{
|
||||
UUID: uuid.NewString(),
|
||||
OwnerUsername: ownerUsername,
|
||||
Name: name,
|
||||
TrackerURL: normalizeProjectTrackerURL(name, req.TrackerURL),
|
||||
Code: code,
|
||||
Variant: variant,
|
||||
Name: namePtr,
|
||||
TrackerURL: normalizeProjectTrackerURL(code, req.TrackerURL),
|
||||
IsActive: true,
|
||||
IsSystem: false,
|
||||
CreatedAt: now,
|
||||
@@ -81,19 +95,32 @@ func (s *ProjectService) Update(projectUUID, ownerUsername string, req *UpdatePr
|
||||
return nil, ErrProjectNotFound
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if name == "" {
|
||||
return nil, fmt.Errorf("project name is required")
|
||||
if req.Code != nil {
|
||||
code := strings.TrimSpace(*req.Code)
|
||||
if code == "" {
|
||||
return nil, fmt.Errorf("project code is required")
|
||||
}
|
||||
localProject.Code = code
|
||||
}
|
||||
if err := s.ensureUniqueProjectName(projectUUID, name); err != nil {
|
||||
if req.Variant != nil {
|
||||
localProject.Variant = strings.TrimSpace(*req.Variant)
|
||||
}
|
||||
if err := s.ensureUniqueProjectCodeVariant(projectUUID, localProject.Code, localProject.Variant); err != nil {
|
||||
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 {
|
||||
localProject.TrackerURL = normalizeProjectTrackerURL(name, *req.TrackerURL)
|
||||
localProject.TrackerURL = normalizeProjectTrackerURL(localProject.Code, *req.TrackerURL)
|
||||
} else if strings.TrimSpace(localProject.TrackerURL) == "" {
|
||||
localProject.TrackerURL = normalizeProjectTrackerURL(name, "")
|
||||
localProject.TrackerURL = normalizeProjectTrackerURL(localProject.Code, "")
|
||||
}
|
||||
localProject.UpdatedAt = time.Now()
|
||||
localProject.SyncStatus = "pending"
|
||||
@@ -106,10 +133,11 @@ 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")
|
||||
func (s *ProjectService) ensureUniqueProjectCodeVariant(excludeUUID, code, variant string) error {
|
||||
normalizedCode := normalizeProjectCode(code)
|
||||
normalizedVariant := normalizeProjectVariant(variant)
|
||||
if normalizedCode == "" {
|
||||
return fmt.Errorf("project code is required")
|
||||
}
|
||||
|
||||
projects, err := s.localDB.GetAllProjects(true)
|
||||
@@ -121,15 +149,20 @@ func (s *ProjectService) ensureUniqueProjectName(excludeUUID, name string) error
|
||||
if excludeUUID != "" && project.UUID == excludeUUID {
|
||||
continue
|
||||
}
|
||||
if normalizeProjectName(project.Name) == normalized {
|
||||
return ErrProjectNameExists
|
||||
if normalizeProjectCode(project.Code) == normalizedCode &&
|
||||
normalizeProjectVariant(project.Variant) == normalizedVariant {
|
||||
return ErrProjectCodeExists
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeProjectName(name string) string {
|
||||
return strings.ToLower(strings.TrimSpace(name))
|
||||
func normalizeProjectCode(code string) string {
|
||||
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 {
|
||||
|
||||
@@ -200,6 +200,7 @@ func (s *Service) ImportProjectsToLocal() (*ProjectImportResult, error) {
|
||||
}
|
||||
|
||||
existing.OwnerUsername = project.OwnerUsername
|
||||
existing.Code = project.Code
|
||||
existing.Name = project.Name
|
||||
existing.TrackerURL = project.TrackerURL
|
||||
existing.IsActive = project.IsActive
|
||||
@@ -848,6 +849,12 @@ func (s *Service) pushProjectChange(change *localdb.PendingChange) error {
|
||||
projectRepo := repository.NewProjectRepository(mariaDB)
|
||||
project := payload.Snapshot
|
||||
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 {
|
||||
return fmt.Errorf("upsert project on server: %w", err)
|
||||
@@ -868,6 +875,17 @@ func (s *Service) pushProjectChange(change *localdb.PendingChange) error {
|
||||
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) {
|
||||
var payload ProjectChangePayload
|
||||
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{
|
||||
UUID: uuid.NewString(),
|
||||
OwnerUsername: "",
|
||||
Name: "Без проекта",
|
||||
Code: "Без проекта",
|
||||
Name: ptrString("Без проекта"),
|
||||
IsActive: 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 {
|
||||
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
|
||||
}
|
||||
|
||||
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:
|
||||
// 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.
|
||||
|
||||
@@ -23,7 +23,7 @@ func TestPushPendingChangesProjectsBeforeConfigurations(t *testing.T) {
|
||||
projectService := services.NewProjectService(local)
|
||||
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 {
|
||||
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 })
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -100,8 +100,8 @@ func TestPushPendingChangesProjectCreateThenUpdateBeforeFirstPush(t *testing.T)
|
||||
if err := serverDB.Where("uuid = ?", project.UUID).First(&serverProject).Error; err != nil {
|
||||
t.Fatalf("project not pushed to server: %v", err)
|
||||
}
|
||||
if serverProject.Name != "Project v2" {
|
||||
t.Fatalf("expected latest project name, got %q", serverProject.Name)
|
||||
if serverProject.Name == nil || *serverProject.Name != "Project v2" {
|
||||
t.Fatalf("expected latest project name, got %v", serverProject.Name)
|
||||
}
|
||||
|
||||
var serverCfg models.Configuration
|
||||
@@ -324,6 +324,8 @@ CREATE TABLE qt_projects (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
uuid TEXT NOT NULL UNIQUE,
|
||||
owner_username TEXT NOT NULL,
|
||||
code TEXT NOT NULL,
|
||||
variant TEXT NOT NULL DEFAULT '',
|
||||
name TEXT NOT NULL,
|
||||
tracker_url TEXT NULL,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
@@ -333,6 +335,9 @@ CREATE TABLE qt_projects (
|
||||
);`).Error; err != nil {
|
||||
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(`
|
||||
CREATE TABLE qt_configurations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
@@ -364,6 +369,10 @@ CREATE TABLE qt_configurations (
|
||||
return db
|
||||
}
|
||||
|
||||
func ptrString(value string) *string {
|
||||
return &value
|
||||
}
|
||||
|
||||
func getCurrentVersionInfo(t *testing.T, local *localdb.LocalDB, configurationUUID string, currentVersionID *string) (int, string) {
|
||||
t.Helper()
|
||||
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>
|
||||
<input id="create-project-input"
|
||||
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">
|
||||
<datalist id="create-project-options"></datalist>
|
||||
<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>
|
||||
<input id="move-project-input"
|
||||
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">
|
||||
<datalist id="move-project-options"></datalist>
|
||||
<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 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>
|
||||
<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">
|
||||
<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>
|
||||
@@ -191,10 +201,12 @@ let configStatusMode = 'active';
|
||||
let configsSearch = '';
|
||||
let projectsCache = [];
|
||||
let projectNameByUUID = {};
|
||||
let projectCodeByUUID = {};
|
||||
let projectVariantByUUID = {};
|
||||
let pendingMoveConfigUUID = '';
|
||||
let pendingMoveProjectName = '';
|
||||
let pendingMoveProjectCode = '';
|
||||
let pendingCreateConfigName = '';
|
||||
let pendingCreateProjectName = '';
|
||||
let pendingCreateProjectCode = '';
|
||||
|
||||
function renderConfigs(configs) {
|
||||
const emptyText = configStatusMode === 'archived'
|
||||
@@ -307,6 +319,30 @@ function renderConfigs(configs) {
|
||||
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) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
@@ -444,21 +480,21 @@ async function createConfig() {
|
||||
return;
|
||||
}
|
||||
|
||||
const projectName = document.getElementById('create-project-input').value.trim();
|
||||
const projectCode = document.getElementById('create-project-input').value.trim();
|
||||
let projectUUID = '';
|
||||
|
||||
if (projectName) {
|
||||
const matchedProject = projectsCache.find(p => p.name.toLowerCase() === projectName.toLowerCase());
|
||||
if (projectCode) {
|
||||
const matchedProject = findProjectByInput(projectCode);
|
||||
if (matchedProject) {
|
||||
if (!matchedProject.is_active) {
|
||||
alert('Проект с таким названием находится в архиве. Восстановите его или выберите другой.');
|
||||
alert('Проект с таким кодом находится в архиве. Восстановите его или выберите другой.');
|
||||
return;
|
||||
}
|
||||
projectUUID = matchedProject.uuid;
|
||||
} else {
|
||||
pendingCreateConfigName = name;
|
||||
pendingCreateProjectName = projectName;
|
||||
openCreateProjectOnCreateModal(projectName);
|
||||
pendingCreateProjectCode = projectCode;
|
||||
openCreateProjectOnCreateModal(projectCode);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -506,12 +542,14 @@ function openMoveProjectModal(uuid, configName, currentProjectUUID) {
|
||||
projectsCache.forEach(project => {
|
||||
if (!project.is_active) return;
|
||||
const option = document.createElement('option');
|
||||
option.value = project.name;
|
||||
option.value = projectDisplayKey(project);
|
||||
option.label = project.name || '';
|
||||
options.appendChild(option);
|
||||
});
|
||||
|
||||
if (currentProjectUUID && projectNameByUUID[currentProjectUUID]) {
|
||||
input.value = projectNameByUUID[currentProjectUUID];
|
||||
if (currentProjectUUID && projectCodeByUUID[currentProjectUUID]) {
|
||||
const variant = projectVariantByUUID[currentProjectUUID] || '';
|
||||
input.value = variant ? (projectCodeByUUID[currentProjectUUID] + ' (' + variant + ')') : projectCodeByUUID[currentProjectUUID];
|
||||
} else {
|
||||
input.value = '';
|
||||
}
|
||||
@@ -527,23 +565,23 @@ function closeMoveProjectModal() {
|
||||
|
||||
async function confirmMoveProject() {
|
||||
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;
|
||||
let projectUUID = '';
|
||||
|
||||
if (projectName) {
|
||||
const matchedProject = projectsCache.find(p => p.name.toLowerCase() === projectName.toLowerCase());
|
||||
if (projectCode) {
|
||||
const matchedProject = findProjectByInput(projectCode);
|
||||
if (matchedProject) {
|
||||
if (!matchedProject.is_active) {
|
||||
alert('Проект с таким названием находится в архиве. Восстановите его или выберите другой.');
|
||||
alert('Проект с таким кодом находится в архиве. Восстановите его или выберите другой.');
|
||||
return;
|
||||
}
|
||||
projectUUID = matchedProject.uuid;
|
||||
} else {
|
||||
pendingMoveConfigUUID = uuid;
|
||||
pendingMoveProjectName = projectName;
|
||||
openCreateProjectOnMoveModal(projectName);
|
||||
pendingMoveProjectCode = projectCode;
|
||||
openCreateProjectOnMoveModal(projectCode);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -560,7 +598,9 @@ function clearCreateProjectInput() {
|
||||
}
|
||||
|
||||
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-confirm-btn').textContent = 'Создать и привязать';
|
||||
document.getElementById('create-project-on-move-modal').classList.remove('hidden');
|
||||
@@ -568,7 +608,9 @@ function openCreateProjectOnMoveModal(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-confirm-btn').textContent = 'Создать и использовать';
|
||||
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.remove('flex');
|
||||
pendingMoveConfigUUID = '';
|
||||
pendingMoveProjectName = '';
|
||||
pendingMoveProjectCode = '';
|
||||
pendingCreateConfigName = '';
|
||||
pendingCreateProjectName = '';
|
||||
pendingCreateProjectCode = '';
|
||||
document.getElementById('create-project-on-move-name').value = '';
|
||||
document.getElementById('create-project-on-move-variant').value = '';
|
||||
}
|
||||
|
||||
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 projectName = pendingCreateProjectName;
|
||||
const projectCode = pendingCreateProjectCode;
|
||||
try {
|
||||
const createResp = await fetch('/api/projects', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({ name: projectName })
|
||||
body: JSON.stringify({ name: projectName, code: projectCode, variant: projectVariant })
|
||||
});
|
||||
if (!createResp.ok) {
|
||||
if (createResp.status === 409) {
|
||||
alert('Проект с таким названием уже существует');
|
||||
alert('Проект с таким кодом и вариантом уже существует');
|
||||
return;
|
||||
}
|
||||
const err = await createResp.json();
|
||||
@@ -606,14 +654,14 @@ async function confirmCreateProjectOnMove() {
|
||||
|
||||
const newProject = await createResp.json();
|
||||
pendingCreateConfigName = '';
|
||||
pendingCreateProjectName = '';
|
||||
pendingCreateProjectCode = '';
|
||||
await loadProjectsForConfigUI();
|
||||
const created = await createConfigWithProject(configName, newProject.uuid);
|
||||
if (created) {
|
||||
closeCreateProjectOnMoveModal();
|
||||
} else {
|
||||
closeCreateProjectOnMoveModal();
|
||||
document.getElementById('create-project-input').value = projectName;
|
||||
document.getElementById('create-project-input').value = projectCode;
|
||||
}
|
||||
} catch (e) {
|
||||
alert('Ошибка создания проекта');
|
||||
@@ -622,8 +670,8 @@ async function confirmCreateProjectOnMove() {
|
||||
}
|
||||
|
||||
const configUUID = pendingMoveConfigUUID;
|
||||
const projectName = pendingMoveProjectName;
|
||||
if (!configUUID || !projectName) {
|
||||
const projectCode = pendingMoveProjectCode;
|
||||
if (!configUUID || !projectCode) {
|
||||
closeCreateProjectOnMoveModal();
|
||||
return;
|
||||
}
|
||||
@@ -632,11 +680,11 @@ async function confirmCreateProjectOnMove() {
|
||||
const createResp = await fetch('/api/projects', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({ name: projectName })
|
||||
body: JSON.stringify({ name: projectName, code: projectCode, variant: projectVariant })
|
||||
});
|
||||
if (!createResp.ok) {
|
||||
if (createResp.status === 409) {
|
||||
alert('Проект с таким названием уже существует');
|
||||
alert('Проект с таким кодом и вариантом уже существует');
|
||||
return;
|
||||
}
|
||||
const err = await createResp.json();
|
||||
@@ -646,9 +694,9 @@ async function confirmCreateProjectOnMove() {
|
||||
|
||||
const newProject = await createResp.json();
|
||||
pendingMoveConfigUUID = '';
|
||||
pendingMoveProjectName = '';
|
||||
pendingMoveProjectCode = '';
|
||||
await loadProjectsForConfigUI();
|
||||
document.getElementById('move-project-input').value = projectName;
|
||||
document.getElementById('move-project-input').value = projectCode;
|
||||
const moved = await moveConfigToProject(configUUID, newProject.uuid);
|
||||
if (moved) {
|
||||
closeCreateProjectOnMoveModal();
|
||||
@@ -835,6 +883,8 @@ document.getElementById('configs-search').addEventListener('input', function(e)
|
||||
async function loadProjectsForConfigUI() {
|
||||
projectsCache = [];
|
||||
projectNameByUUID = {};
|
||||
projectCodeByUUID = {};
|
||||
projectVariantByUUID = {};
|
||||
try {
|
||||
// Use /api/projects/all to get all projects without pagination
|
||||
const resp = await fetch('/api/projects/all');
|
||||
@@ -847,7 +897,11 @@ async function loadProjectsForConfigUI() {
|
||||
projectsCache = allProjects;
|
||||
|
||||
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');
|
||||
@@ -856,7 +910,8 @@ async function loadProjectsForConfigUI() {
|
||||
projectsCache.forEach(project => {
|
||||
if (!project.is_active) return;
|
||||
const option = document.createElement('option');
|
||||
option.value = project.name;
|
||||
option.value = projectDisplayKey(project);
|
||||
option.label = project.name || '';
|
||||
createOptions.appendChild(option);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,14 +5,25 @@
|
||||
<!-- Header with config name and back button -->
|
||||
<div class="flex items-center justify-between">
|
||||
<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">
|
||||
<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>
|
||||
</a>
|
||||
<h1 class="text-2xl font-bold">
|
||||
<span id="config-name">Конфигуратор</span>
|
||||
</h1>
|
||||
<div class="text-2xl font-bold flex items-center gap-2" id="config-breadcrumbs">
|
||||
<a id="breadcrumb-project-code-link" href="/projects" class="text-blue-700 hover:underline">
|
||||
<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 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">
|
||||
@@ -329,6 +340,67 @@ let configUUID = '{{.ConfigUUID}}';
|
||||
let configName = '';
|
||||
let projectUUID = '';
|
||||
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 allComponents = [];
|
||||
let cart = [];
|
||||
@@ -617,7 +689,8 @@ document.addEventListener('DOMContentLoaded', async function() {
|
||||
const config = await resp.json();
|
||||
configName = config.name;
|
||||
projectUUID = config.project_uuid || '';
|
||||
document.getElementById('config-name').textContent = config.name;
|
||||
await loadProjectIndex();
|
||||
updateConfigBreadcrumbs();
|
||||
document.getElementById('save-buttons').classList.remove('hidden');
|
||||
|
||||
// Set server count from config
|
||||
|
||||
@@ -3,9 +3,10 @@
|
||||
{{define "content"}}
|
||||
<div class="space-y-6">
|
||||
<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">
|
||||
<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>
|
||||
</a>
|
||||
<h1 id="page-title" class="text-2xl font-bold text-gray-900">Загрузка...</h1>
|
||||
|
||||
@@ -4,23 +4,43 @@
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between 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">
|
||||
<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>
|
||||
</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 id="action-buttons" class="mt-4 grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<button onclick="openCreateModal()" class="py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium">
|
||||
<div id="action-buttons" class="mt-4 grid grid-cols-1 sm:grid-cols-4 gap-3">
|
||||
<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 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 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>
|
||||
</div>
|
||||
@@ -61,6 +81,33 @@
|
||||
</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 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>
|
||||
@@ -120,6 +167,16 @@
|
||||
<div class="bg-white rounded-lg shadow-xl w-full max-w-md mx-4 p-6">
|
||||
<h2 class="text-xl font-semibold mb-4">Параметры проекта</h2>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Код проекта</label>
|
||||
<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>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Название проекта</label>
|
||||
<input type="text" id="project-settings-name"
|
||||
@@ -144,6 +201,8 @@ const projectUUID = '{{.ProjectUUID}}';
|
||||
let configStatusMode = 'active';
|
||||
let project = null;
|
||||
let allConfigs = [];
|
||||
let projectVariants = [];
|
||||
let variantMenuInitialized = false;
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
@@ -157,6 +216,91 @@ function resolveProjectTrackerURL(projectData) {
|
||||
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) {
|
||||
if (mode !== 'active' && mode !== 'archived') return;
|
||||
configStatusMode = mode;
|
||||
@@ -254,7 +398,9 @@ async function loadProject() {
|
||||
return false;
|
||||
}
|
||||
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');
|
||||
if (trackerLink) {
|
||||
if (project && project.is_system) {
|
||||
@@ -297,6 +443,56 @@ function openCreateModal() {
|
||||
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() {
|
||||
document.getElementById('create-modal').classList.add('hidden');
|
||||
document.getElementById('create-modal').classList.remove('flex');
|
||||
@@ -429,6 +625,8 @@ function openProjectSettingsModal() {
|
||||
alert('Системный проект нельзя редактировать');
|
||||
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-tracker-url').value = (project.tracker_url || '').trim();
|
||||
document.getElementById('project-settings-modal').classList.remove('hidden');
|
||||
@@ -442,27 +640,31 @@ function closeProjectSettingsModal() {
|
||||
|
||||
async function saveProjectSettings() {
|
||||
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 trackerURL = document.getElementById('project-settings-tracker-url').value.trim();
|
||||
if (!name) {
|
||||
alert('Введите название проекта');
|
||||
if (!code) {
|
||||
alert('Введите код проекта');
|
||||
return;
|
||||
}
|
||||
const resp = await fetch('/api/projects/' + projectUUID, {
|
||||
method: 'PUT',
|
||||
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.status === 409) {
|
||||
alert('Проект с таким названием уже существует');
|
||||
alert('Проект с таким кодом и вариантом уже существует');
|
||||
return;
|
||||
}
|
||||
alert('Не удалось сохранить параметры проекта');
|
||||
return;
|
||||
}
|
||||
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');
|
||||
if (trackerLink) {
|
||||
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('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('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(); });
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
</div>
|
||||
|
||||
<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">
|
||||
</div>
|
||||
|
||||
@@ -31,11 +31,21 @@
|
||||
<div class="bg-white rounded-lg shadow-xl w-full max-w-md mx-4 p-6">
|
||||
<h2 class="text-xl font-semibold mb-4">Новый проект</h2>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label 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>
|
||||
<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"
|
||||
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
</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>
|
||||
<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"
|
||||
@@ -59,6 +69,8 @@ let sortField = 'created_at';
|
||||
let sortDir = 'desc';
|
||||
let createProjectTrackerManuallyEdited = false;
|
||||
let createProjectLastAutoTrackerURL = '';
|
||||
let variantsByCode = {};
|
||||
let variantsLoaded = false;
|
||||
|
||||
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) {
|
||||
if (sortField === field) {
|
||||
sortDir = sortDir === 'asc' ? 'desc' : 'asc';
|
||||
@@ -132,10 +193,33 @@ async function loadProjects() {
|
||||
}
|
||||
const data = await resp.json();
|
||||
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;
|
||||
totalPages = data.total_pages || 0;
|
||||
page = data.page || currentPage;
|
||||
currentPage = page;
|
||||
await loadVariantsIndex();
|
||||
} catch (e) {
|
||||
root.innerHTML = '<div class="text-red-600">Ошибка загрузки проектов: ' + escapeHtml(String(e.message || e)) + '</div>';
|
||||
return;
|
||||
@@ -144,27 +228,22 @@ async function loadProjects() {
|
||||
let html = '<div class="overflow-x-auto"><table class="w-full">';
|
||||
html += '<thead class="bg-gray-50">';
|
||||
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 += '<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') {
|
||||
html += sortDir === 'asc' ? ' <span>↑</span>' : ' <span>↓</span>';
|
||||
}
|
||||
html += '</button></th>';
|
||||
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Автор</th>';
|
||||
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">';
|
||||
html += '<button type="button" onclick="toggleSort(\'created_at\')" class="inline-flex items-center gap-1 hover:text-gray-700">Создан';
|
||||
if (sortField === 'created_at') {
|
||||
html += sortDir === 'asc' ? ' <span>↑</span>' : ' <span>↓</span>';
|
||||
}
|
||||
html += '</button></th>';
|
||||
html += '<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Кол-во квот</th>';
|
||||
html += '<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Сумма</th>';
|
||||
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Создан @ автор</th>';
|
||||
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Изменен @ кто</th>';
|
||||
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Варианты</th>';
|
||||
html += '<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Действия</th>';
|
||||
html += '</tr>';
|
||||
html += '<tr>';
|
||||
html += '<th class="px-4 py-2"></th>';
|
||||
html += '<th class="px-4 py-2"><input id="projects-author-filter" type="text" value="' + escapeHtml(authorSearch) + '" placeholder="Фильтр автора" class="w-full px-2 py-1 border rounded text-xs focus:ring-1 focus:ring-blue-500 focus:border-blue-500"></th>';
|
||||
html += '<th class="px-4 py-2"></th>';
|
||||
html += '<th class="px-4 py-2"><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>';
|
||||
@@ -175,21 +254,28 @@ async function loadProjects() {
|
||||
html += '<tr><td colspan="6" class="px-4 py-6 text-sm text-gray-500 text-center">Проектов нет</td></tr>';
|
||||
}
|
||||
|
||||
rows.forEach(p => {
|
||||
html += '<tr class="hover:bg-gray-50">';
|
||||
html += '<td class="px-4 py-3 text-sm font-medium"><a class="text-blue-600 hover:underline" href="/projects/' + p.uuid + '">' + escapeHtml(p.name) + '</a></td>';
|
||||
html += '<td class="px-4 py-3 text-sm text-gray-600">' + escapeHtml(p.owner_username || '—') + '</td>';
|
||||
html += '<td class="px-4 py-3 text-sm text-gray-600">' + escapeHtml(formatDateTime(p.created_at)) + '</td>';
|
||||
html += '<td class="px-4 py-3 text-sm text-right text-gray-700">' + (p.config_count || 0) + '</td>';
|
||||
html += '<td class="px-4 py-3 text-sm text-right text-gray-700">' + formatMoney(p.total) + '</td>';
|
||||
rows.forEach(p => {
|
||||
html += '<tr class="hover:bg-gray-50">';
|
||||
const displayName = p.name || '';
|
||||
const createdBy = p.owner_username || '—';
|
||||
const updatedBy = '—';
|
||||
const createdLabel = formatDateTime(p.created_at) + ' @ ' + createdBy;
|
||||
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">';
|
||||
|
||||
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 += '</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 += '</button>';
|
||||
|
||||
@@ -251,15 +337,19 @@ function buildTrackerURLFromProjectCode(projectCode) {
|
||||
}
|
||||
|
||||
function openCreateProjectModal() {
|
||||
const nameInput = document.getElementById('create-project-name');
|
||||
const codeInput = document.getElementById('create-project-code');
|
||||
const variantInput = document.getElementById('create-project-variant');
|
||||
const trackerInput = document.getElementById('create-project-tracker-url');
|
||||
nameInput.value = '';
|
||||
codeInput.value = '';
|
||||
variantInput.value = '';
|
||||
trackerInput.value = '';
|
||||
createProjectTrackerManuallyEdited = false;
|
||||
createProjectLastAutoTrackerURL = '';
|
||||
document.getElementById('create-project-modal').classList.remove('hidden');
|
||||
document.getElementById('create-project-modal').classList.add('flex');
|
||||
codeInput.focus();
|
||||
nameInput.focus();
|
||||
}
|
||||
|
||||
function closeCreateProjectModal() {
|
||||
@@ -278,10 +368,14 @@ function updateCreateProjectTrackerURL() {
|
||||
}
|
||||
|
||||
async function createProject() {
|
||||
const nameInput = document.getElementById('create-project-name');
|
||||
const codeInput = document.getElementById('create-project-code');
|
||||
const variantInput = document.getElementById('create-project-variant');
|
||||
const trackerInput = document.getElementById('create-project-tracker-url');
|
||||
const name = (codeInput.value || '').trim();
|
||||
if (!name) {
|
||||
const name = (nameInput.value || '').trim();
|
||||
const code = (codeInput.value || '').trim();
|
||||
const variant = (variantInput.value || '').trim();
|
||||
if (!code) {
|
||||
alert('Введите код проекта');
|
||||
return;
|
||||
}
|
||||
@@ -290,12 +384,14 @@ async function createProject() {
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
name: name,
|
||||
code: code,
|
||||
variant: variant,
|
||||
tracker_url: (trackerInput.value || '').trim()
|
||||
})
|
||||
});
|
||||
if (!resp.ok) {
|
||||
if (resp.status === 409) {
|
||||
alert('Проект с таким названием уже существует');
|
||||
alert('Проект с таким кодом и вариантом уже существует');
|
||||
return;
|
||||
}
|
||||
alert('Не удалось создать проект');
|
||||
@@ -361,15 +457,18 @@ async function addConfigToProject(projectUUID) {
|
||||
async function copyProject(projectUUID, projectName) {
|
||||
const newName = prompt('Название копии проекта', projectName + ' (копия)');
|
||||
if (!newName || !newName.trim()) return;
|
||||
const newCode = prompt('Код проекта', '');
|
||||
if (!newCode || !newCode.trim()) return;
|
||||
const newVariant = prompt('Вариант (необязательно)', '');
|
||||
|
||||
const createResp = await fetch('/api/projects', {
|
||||
method: 'POST',
|
||||
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.status === 409) {
|
||||
alert('Проект с таким названием уже существует');
|
||||
alert('Проект с таким кодом и вариантом уже существует');
|
||||
return;
|
||||
}
|
||||
alert('Не удалось создать копию проекта');
|
||||
@@ -410,6 +509,13 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
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) {
|
||||
createProjectTrackerManuallyEdited = (e.target.value || '').trim() !== createProjectLastAutoTrackerURL;
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user