projects: add tracker_url and project create modal
This commit is contained in:
@@ -1130,6 +1130,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
||||
"uuid": p.UUID,
|
||||
"owner_username": p.OwnerUsername,
|
||||
"name": p.Name,
|
||||
"tracker_url": p.TrackerURL,
|
||||
"is_active": p.IsActive,
|
||||
"is_system": p.IsSystem,
|
||||
"created_at": p.CreatedAt,
|
||||
|
||||
@@ -99,6 +99,7 @@ func ProjectToLocal(project *models.Project) *LocalProject {
|
||||
UUID: project.UUID,
|
||||
OwnerUsername: project.OwnerUsername,
|
||||
Name: project.Name,
|
||||
TrackerURL: project.TrackerURL,
|
||||
IsActive: project.IsActive,
|
||||
IsSystem: project.IsSystem,
|
||||
CreatedAt: project.CreatedAt,
|
||||
@@ -117,6 +118,7 @@ func LocalToProject(local *LocalProject) *models.Project {
|
||||
UUID: local.UUID,
|
||||
OwnerUsername: local.OwnerUsername,
|
||||
Name: local.Name,
|
||||
TrackerURL: local.TrackerURL,
|
||||
IsActive: local.IsActive,
|
||||
IsSystem: local.IsSystem,
|
||||
CreatedAt: local.CreatedAt,
|
||||
|
||||
@@ -94,6 +94,7 @@ type LocalProject struct {
|
||||
ServerID *uint `json:"server_id,omitempty"`
|
||||
OwnerUsername string `gorm:"not null;index" json:"owner_username"`
|
||||
Name string `gorm:"not null" json:"name"`
|
||||
TrackerURL string `json:"tracker_url"`
|
||||
IsActive bool `gorm:"default:true;index" json:"is_active"`
|
||||
IsSystem bool `gorm:"default:false;index" json:"is_system"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
|
||||
@@ -7,6 +7,7 @@ type Project struct {
|
||||
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"`
|
||||
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"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
|
||||
@@ -3,6 +3,7 @@ package repository
|
||||
import (
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type ProjectRepository struct {
|
||||
@@ -21,6 +22,30 @@ func (r *ProjectRepository) Update(project *models.Project) error {
|
||||
return r.db.Save(project).Error
|
||||
}
|
||||
|
||||
func (r *ProjectRepository) UpsertByUUID(project *models.Project) error {
|
||||
if err := r.db.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "uuid"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{
|
||||
"owner_username",
|
||||
"name",
|
||||
"tracker_url",
|
||||
"is_active",
|
||||
"is_system",
|
||||
"updated_at",
|
||||
}),
|
||||
}).Create(project).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Ensure caller always gets canonical server ID.
|
||||
var persisted models.Project
|
||||
if err := r.db.Where("uuid = ?", project.UUID).First(&persisted).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
project.ID = persisted.ID
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *ProjectRepository) GetByUUID(uuid string) (*models.Project, error) {
|
||||
var project models.Project
|
||||
if err := r.db.Where("uuid = ?", uuid).First(&project).Error; err != nil {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -28,11 +29,13 @@ func NewProjectService(localDB *localdb.LocalDB) *ProjectService {
|
||||
}
|
||||
|
||||
type CreateProjectRequest struct {
|
||||
Name string `json:"name"`
|
||||
Name string `json:"name"`
|
||||
TrackerURL string `json:"tracker_url"`
|
||||
}
|
||||
|
||||
type UpdateProjectRequest struct {
|
||||
Name string `json:"name"`
|
||||
Name string `json:"name"`
|
||||
TrackerURL *string `json:"tracker_url,omitempty"`
|
||||
}
|
||||
|
||||
type ProjectConfigurationsResult struct {
|
||||
@@ -52,6 +55,7 @@ func (s *ProjectService) Create(ownerUsername string, req *CreateProjectRequest)
|
||||
UUID: uuid.NewString(),
|
||||
OwnerUsername: ownerUsername,
|
||||
Name: name,
|
||||
TrackerURL: normalizeProjectTrackerURL(name, req.TrackerURL),
|
||||
IsActive: true,
|
||||
IsSystem: false,
|
||||
CreatedAt: now,
|
||||
@@ -82,6 +86,11 @@ func (s *ProjectService) Update(projectUUID, ownerUsername string, req *UpdatePr
|
||||
}
|
||||
|
||||
localProject.Name = name
|
||||
if req.TrackerURL != nil {
|
||||
localProject.TrackerURL = normalizeProjectTrackerURL(name, *req.TrackerURL)
|
||||
} else if strings.TrimSpace(localProject.TrackerURL) == "" {
|
||||
localProject.TrackerURL = normalizeProjectTrackerURL(name, "")
|
||||
}
|
||||
localProject.UpdatedAt = time.Now()
|
||||
localProject.SyncStatus = "pending"
|
||||
if err := s.localDB.SaveProject(localProject); err != nil {
|
||||
@@ -260,6 +269,20 @@ func (s *ProjectService) ResolveProjectUUID(ownerUsername string, projectUUID *s
|
||||
return &resolved, nil
|
||||
}
|
||||
|
||||
func normalizeProjectTrackerURL(projectCode, trackerURL string) string {
|
||||
trimmedURL := strings.TrimSpace(trackerURL)
|
||||
if trimmedURL != "" {
|
||||
return trimmedURL
|
||||
}
|
||||
|
||||
trimmedCode := strings.TrimSpace(projectCode)
|
||||
if trimmedCode == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
return "https://tracker.yandex.ru/" + url.PathEscape(trimmedCode)
|
||||
}
|
||||
|
||||
func (s *ProjectService) enqueueProjectPendingChange(project *localdb.LocalProject, operation string) error {
|
||||
return s.enqueueProjectPendingChangeTx(s.localDB.DB(), project, operation)
|
||||
}
|
||||
|
||||
@@ -201,6 +201,7 @@ func (s *Service) ImportProjectsToLocal() (*ProjectImportResult, error) {
|
||||
|
||||
existing.OwnerUsername = project.OwnerUsername
|
||||
existing.Name = project.Name
|
||||
existing.TrackerURL = project.TrackerURL
|
||||
existing.IsActive = project.IsActive
|
||||
existing.IsSystem = project.IsSystem
|
||||
existing.CreatedAt = project.CreatedAt
|
||||
@@ -757,20 +758,8 @@ func (s *Service) pushProjectChange(change *localdb.PendingChange) error {
|
||||
project := payload.Snapshot
|
||||
project.UUID = payload.ProjectUUID
|
||||
|
||||
serverProject, err := projectRepo.GetByUUID(project.UUID)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
if createErr := projectRepo.Create(&project); createErr != nil {
|
||||
return fmt.Errorf("create project on server: %w", createErr)
|
||||
}
|
||||
} else {
|
||||
return fmt.Errorf("get project on server: %w", err)
|
||||
}
|
||||
} else {
|
||||
project.ID = serverProject.ID
|
||||
if updateErr := projectRepo.Update(&project); updateErr != nil {
|
||||
return fmt.Errorf("update project on server: %w", updateErr)
|
||||
}
|
||||
if err := projectRepo.UpsertByUUID(&project); err != nil {
|
||||
return fmt.Errorf("upsert project on server: %w", err)
|
||||
}
|
||||
|
||||
localProject, localErr := s.localDB.GetProjectByUUID(project.UUID)
|
||||
@@ -1032,7 +1021,7 @@ func (s *Service) ensureConfigurationProject(mariaDB *gorm.DB, cfg *models.Confi
|
||||
if modelProject.OwnerUsername == "" {
|
||||
modelProject.OwnerUsername = cfg.OwnerUsername
|
||||
}
|
||||
if createErr := projectRepo.Create(modelProject); createErr != nil {
|
||||
if createErr := projectRepo.UpsertByUUID(modelProject); createErr != nil {
|
||||
return createErr
|
||||
}
|
||||
if modelProject.ID > 0 {
|
||||
|
||||
@@ -65,6 +65,54 @@ func TestPushPendingChangesProjectsBeforeConfigurations(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPushPendingChangesProjectCreateThenUpdateBeforeFirstPush(t *testing.T) {
|
||||
local := newLocalDBForSyncTest(t)
|
||||
serverDB := newServerDBForSyncTest(t)
|
||||
|
||||
localSync := syncsvc.NewService(nil, local)
|
||||
projectService := services.NewProjectService(local)
|
||||
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"})
|
||||
if err != nil {
|
||||
t.Fatalf("create project: %v", err)
|
||||
}
|
||||
if _, err := projectService.Update(project.UUID, "tester", &services.UpdateProjectRequest{Name: "Project v2"}); err != nil {
|
||||
t.Fatalf("update project: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := configService.Create("tester", &services.CreateConfigRequest{
|
||||
Name: "Cfg linked",
|
||||
Items: models.ConfigItems{{LotName: "CPU_A", Quantity: 1, UnitPrice: 1000}},
|
||||
ServerCount: 1,
|
||||
ProjectUUID: &project.UUID,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create config: %v", err)
|
||||
}
|
||||
|
||||
if _, err := pushService.PushPendingChanges(); err != nil {
|
||||
t.Fatalf("push pending changes: %v", err)
|
||||
}
|
||||
|
||||
var serverProject models.Project
|
||||
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)
|
||||
}
|
||||
|
||||
var serverCfg models.Configuration
|
||||
if err := serverDB.Where("uuid = ?", cfg.UUID).First(&serverCfg).Error; err != nil {
|
||||
t.Fatalf("configuration not pushed to server: %v", err)
|
||||
}
|
||||
if serverCfg.ProjectUUID == nil || *serverCfg.ProjectUUID != project.UUID {
|
||||
t.Fatalf("expected project_uuid=%s on pushed config, got %v", project.UUID, serverCfg.ProjectUUID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPushPendingChangesSkipsStaleUpdateAndAppliesLatest(t *testing.T) {
|
||||
local := newLocalDBForSyncTest(t)
|
||||
serverDB := newServerDBForSyncTest(t)
|
||||
@@ -277,6 +325,7 @@ CREATE TABLE qt_projects (
|
||||
uuid TEXT NOT NULL UNIQUE,
|
||||
owner_username TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
tracker_url TEXT NULL,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
is_system INTEGER NOT NULL DEFAULT 0,
|
||||
created_at DATETIME,
|
||||
|
||||
7
migrations/012_add_project_tracker_url.sql
Normal file
7
migrations/012_add_project_tracker_url.sql
Normal file
@@ -0,0 +1,7 @@
|
||||
ALTER TABLE qt_projects
|
||||
ADD COLUMN tracker_url VARCHAR(500) NULL AFTER name;
|
||||
|
||||
UPDATE qt_projects
|
||||
SET tracker_url = CONCAT('https://tracker.yandex.ru/', TRIM(name))
|
||||
WHERE (tracker_url IS NULL OR tracker_url = '')
|
||||
AND TRIM(COALESCE(name, '')) <> '';
|
||||
@@ -125,6 +125,12 @@ function escapeHtml(text) {
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function resolveProjectTrackerURL(projectData) {
|
||||
if (!projectData) return '';
|
||||
const explicitURL = (projectData.tracker_url || '').trim();
|
||||
return explicitURL;
|
||||
}
|
||||
|
||||
function setConfigStatusMode(mode) {
|
||||
if (mode !== 'active' && mode !== 'archived') return;
|
||||
configStatusMode = mode;
|
||||
@@ -224,8 +230,18 @@ async function loadProject() {
|
||||
project = await resp.json();
|
||||
document.getElementById('project-title').textContent = project.name;
|
||||
const trackerLink = document.getElementById('tracker-link');
|
||||
if (trackerLink && project && project.name) {
|
||||
trackerLink.href = 'https://tracker.yandex.ru/' + encodeURIComponent(project.name);
|
||||
if (trackerLink) {
|
||||
if (project && project.is_system) {
|
||||
trackerLink.classList.add('hidden');
|
||||
return true;
|
||||
}
|
||||
const trackerURL = resolveProjectTrackerURL(project);
|
||||
if (trackerURL) {
|
||||
trackerLink.href = trackerURL;
|
||||
trackerLink.classList.remove('hidden');
|
||||
} else {
|
||||
trackerLink.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<a href="/configs" class="px-4 py-2 bg-gray-200 text-gray-800 rounded hover:bg-gray-300">
|
||||
Все конфигурации
|
||||
</a>
|
||||
<button onclick="createProject()" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">
|
||||
<button onclick="openCreateProjectModal()" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">
|
||||
+ Новый проект
|
||||
</button>
|
||||
</div>
|
||||
@@ -27,6 +27,28 @@
|
||||
<div id="projects-table" class="bg-white rounded-lg shadow p-4 text-gray-500">Загрузка...</div>
|
||||
</div>
|
||||
|
||||
<div id="create-project-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
|
||||
<div class="bg-white rounded-lg shadow-xl w-full max-w-md mx-4 p-6">
|
||||
<h2 class="text-xl font-semibold mb-4">Новый проект</h2>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label 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-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"
|
||||
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end gap-2 mt-6">
|
||||
<button type="button" onclick="closeCreateProjectModal()" class="px-4 py-2 text-gray-600 hover:text-gray-800">Отмена</button>
|
||||
<button type="button" onclick="createProject()" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">Создать</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let status = 'active';
|
||||
let projectsSearch = '';
|
||||
@@ -35,6 +57,10 @@ let currentPage = 1;
|
||||
let perPage = 10;
|
||||
let sortField = 'created_at';
|
||||
let sortDir = 'desc';
|
||||
let createProjectTrackerManuallyEdited = false;
|
||||
let createProjectLastAutoTrackerURL = '';
|
||||
|
||||
const trackerBaseURL = 'https://tracker.yandex.ru/';
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
@@ -218,18 +244,60 @@ function goToPage(page) {
|
||||
loadProjects();
|
||||
}
|
||||
|
||||
function buildTrackerURLFromProjectCode(projectCode) {
|
||||
const code = (projectCode || '').trim();
|
||||
if (!code) return '';
|
||||
return trackerBaseURL + encodeURIComponent(code);
|
||||
}
|
||||
|
||||
function openCreateProjectModal() {
|
||||
const codeInput = document.getElementById('create-project-code');
|
||||
const trackerInput = document.getElementById('create-project-tracker-url');
|
||||
codeInput.value = '';
|
||||
trackerInput.value = '';
|
||||
createProjectTrackerManuallyEdited = false;
|
||||
createProjectLastAutoTrackerURL = '';
|
||||
document.getElementById('create-project-modal').classList.remove('hidden');
|
||||
document.getElementById('create-project-modal').classList.add('flex');
|
||||
codeInput.focus();
|
||||
}
|
||||
|
||||
function closeCreateProjectModal() {
|
||||
document.getElementById('create-project-modal').classList.add('hidden');
|
||||
document.getElementById('create-project-modal').classList.remove('flex');
|
||||
}
|
||||
|
||||
function updateCreateProjectTrackerURL() {
|
||||
const codeInput = document.getElementById('create-project-code');
|
||||
const trackerInput = document.getElementById('create-project-tracker-url');
|
||||
const generatedURL = buildTrackerURLFromProjectCode(codeInput.value);
|
||||
if (!createProjectTrackerManuallyEdited || trackerInput.value.trim() === '' || trackerInput.value === createProjectLastAutoTrackerURL) {
|
||||
trackerInput.value = generatedURL;
|
||||
createProjectLastAutoTrackerURL = generatedURL;
|
||||
}
|
||||
}
|
||||
|
||||
async function createProject() {
|
||||
const name = prompt('Название проекта');
|
||||
if (!name || !name.trim()) return;
|
||||
const codeInput = document.getElementById('create-project-code');
|
||||
const trackerInput = document.getElementById('create-project-tracker-url');
|
||||
const name = (codeInput.value || '').trim();
|
||||
if (!name) {
|
||||
alert('Введите код проекта');
|
||||
return;
|
||||
}
|
||||
const resp = await fetch('/api/projects', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({name: name.trim()})
|
||||
body: JSON.stringify({
|
||||
name: name,
|
||||
tracker_url: (trackerInput.value || '').trim()
|
||||
})
|
||||
});
|
||||
if (!resp.ok) {
|
||||
alert('Не удалось создать проект');
|
||||
return;
|
||||
}
|
||||
closeCreateProjectModal();
|
||||
loadProjects();
|
||||
}
|
||||
|
||||
@@ -324,6 +392,34 @@ document.getElementById('projects-search').addEventListener('input', function(e)
|
||||
currentPage = 1;
|
||||
loadProjects();
|
||||
});
|
||||
|
||||
document.getElementById('create-project-code').addEventListener('input', function() {
|
||||
updateCreateProjectTrackerURL();
|
||||
});
|
||||
|
||||
document.getElementById('create-project-tracker-url').addEventListener('input', function(e) {
|
||||
createProjectTrackerManuallyEdited = (e.target.value || '').trim() !== createProjectLastAutoTrackerURL;
|
||||
});
|
||||
|
||||
document.getElementById('create-project-code').addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
createProject();
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('create-project-tracker-url').addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
createProject();
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('create-project-modal').addEventListener('click', function(e) {
|
||||
if (e.target === this) {
|
||||
closeCreateProjectModal();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{{end}}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user