fix(qfs): project ui, config naming, sync timestamps - v1.5.4

This commit is contained in:
Mikhail Chusavitin
2026-03-16 08:32:15 +03:00
parent c599897142
commit 35c5600b36
16 changed files with 815 additions and 93 deletions

View File

@@ -34,6 +34,7 @@ Readiness guard:
- every sync push/pull runs a preflight check;
- blocked sync returns `423 Locked` with a machine-readable reason;
- local work continues even when sync is blocked.
- sync metadata updates must preserve project `updated_at`; sync time belongs in `synced_at`, not in the user-facing last-modified timestamp.
## Pricing contract
@@ -55,6 +56,15 @@ Rules:
- rename, reorder, project move, and similar operational edits do not create a new revision snapshot;
- current revision pointer must be recoverable if legacy or damaged rows are found locally.
## Naming collisions
UI-driven rename and copy flows use one suffix convention for conflicts.
Rules:
- configuration and variant names must auto-resolve collisions with `_копия`, then `_копия2`, `_копия3`, and so on;
- copy checkboxes and copy modals must prefill `_копия`, not ` (копия)`;
- the literal variant name `main` is reserved and must not be allowed for non-main variants.
## Vendor BOM contract
Vendor BOM is stored in `vendor_spec` on the configuration row.

View File

@@ -36,6 +36,7 @@ logging:
Rules:
- QuoteForge creates this file automatically if it does not exist;
- startup rewrites legacy config files into this minimal runtime shape;
- startup normalizes any `server.host` value to `127.0.0.1` before saving the runtime config;
- `server.host` must stay on loopback.
Saved MariaDB credentials do not live in `config.yaml`.

View File

@@ -5,6 +5,7 @@
```bash
go run ./cmd/qfs
go run ./cmd/qfs -migrate
go run ./cmd/migrate_project_updated_at
go test ./...
go vet ./...
make build-release

View File

@@ -0,0 +1,173 @@
package main
import (
"flag"
"fmt"
"log"
"sort"
"time"
"git.mchus.pro/mchus/quoteforge/internal/appstate"
"git.mchus.pro/mchus/quoteforge/internal/localdb"
"git.mchus.pro/mchus/quoteforge/internal/models"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
type projectTimestampRow struct {
UUID string
UpdatedAt time.Time
}
type updatePlanRow struct {
UUID string
Code string
Variant string
LocalUpdatedAt time.Time
ServerUpdatedAt time.Time
}
func main() {
defaultLocalDBPath, err := appstate.ResolveDBPath("")
if err != nil {
log.Fatalf("failed to resolve default local SQLite path: %v", err)
}
localDBPath := flag.String("localdb", defaultLocalDBPath, "path to local SQLite database (default: user state dir or QFS_DB_PATH)")
apply := flag.Bool("apply", false, "apply updates to local SQLite (default is preview only)")
flag.Parse()
local, err := localdb.New(*localDBPath)
if err != nil {
log.Fatalf("failed to initialize local database: %v", err)
}
defer local.Close()
if !local.HasSettings() {
log.Fatalf("SQLite connection settings are not configured. Run qfs setup first.")
}
dsn, err := local.GetDSN()
if err != nil {
log.Fatalf("failed to build DSN from SQLite settings: %v", err)
}
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
log.Fatalf("failed to connect to MariaDB: %v", err)
}
serverRows, err := loadServerProjects(db)
if err != nil {
log.Fatalf("failed to load server projects: %v", err)
}
localProjects, err := local.GetAllProjects(true)
if err != nil {
log.Fatalf("failed to load local projects: %v", err)
}
plan := buildUpdatePlan(localProjects, serverRows)
printPlan(plan, *apply)
if !*apply || len(plan) == 0 {
return
}
updated := 0
for i := range plan {
project, err := local.GetProjectByUUID(plan[i].UUID)
if err != nil {
log.Printf("skip %s: load local project: %v", plan[i].UUID, err)
continue
}
project.UpdatedAt = plan[i].ServerUpdatedAt
if err := local.SaveProjectPreservingUpdatedAt(project); err != nil {
log.Printf("skip %s: save local project: %v", plan[i].UUID, err)
continue
}
updated++
}
log.Printf("updated %d local project timestamps", updated)
}
func loadServerProjects(db *gorm.DB) (map[string]time.Time, error) {
var rows []projectTimestampRow
if err := db.Model(&models.Project{}).
Select("uuid, updated_at").
Find(&rows).Error; err != nil {
return nil, err
}
out := make(map[string]time.Time, len(rows))
for _, row := range rows {
if row.UUID == "" {
continue
}
out[row.UUID] = row.UpdatedAt
}
return out, nil
}
func buildUpdatePlan(localProjects []localdb.LocalProject, serverRows map[string]time.Time) []updatePlanRow {
plan := make([]updatePlanRow, 0)
for i := range localProjects {
project := localProjects[i]
serverUpdatedAt, ok := serverRows[project.UUID]
if !ok {
continue
}
if project.UpdatedAt.Equal(serverUpdatedAt) {
continue
}
plan = append(plan, updatePlanRow{
UUID: project.UUID,
Code: project.Code,
Variant: project.Variant,
LocalUpdatedAt: project.UpdatedAt,
ServerUpdatedAt: serverUpdatedAt,
})
}
sort.Slice(plan, func(i, j int) bool {
if plan[i].Code != plan[j].Code {
return plan[i].Code < plan[j].Code
}
return plan[i].Variant < plan[j].Variant
})
return plan
}
func printPlan(plan []updatePlanRow, apply bool) {
mode := "preview"
if apply {
mode = "apply"
}
log.Printf("project updated_at resync mode=%s changes=%d", mode, len(plan))
if len(plan) == 0 {
log.Printf("no local project timestamps need resync")
return
}
for _, row := range plan {
variant := row.Variant
if variant == "" {
variant = "main"
}
log.Printf("%s [%s] local=%s server=%s", row.Code, variant, formatStamp(row.LocalUpdatedAt), formatStamp(row.ServerUpdatedAt))
}
if !apply {
fmt.Println("Re-run with -apply to write server updated_at into local SQLite.")
}
}
func formatStamp(value time.Time) string {
if value.IsZero() {
return "zero"
}
return value.Format(time.RFC3339)
}

View File

@@ -39,6 +39,10 @@ logging:
t.Fatalf("load legacy config: %v", err)
}
setConfigDefaults(cfg)
cfg.Server.Host, _, err = normalizeLoopbackServerHost(cfg.Server.Host)
if err != nil {
t.Fatalf("normalize server host: %v", err)
}
if err := migrateConfigFileToRuntimeShape(path, cfg); err != nil {
t.Fatalf("migrate config: %v", err)
}
@@ -60,32 +64,43 @@ logging:
if !strings.Contains(text, "port: 9191") {
t.Fatalf("migrated config did not preserve server port:\n%s", text)
}
if !strings.Contains(text, "host: 127.0.0.1") {
t.Fatalf("migrated config did not normalize server host:\n%s", text)
}
if !strings.Contains(text, "level: debug") {
t.Fatalf("migrated config did not preserve logging level:\n%s", text)
}
}
func TestEnsureLoopbackServerHost(t *testing.T) {
func TestNormalizeLoopbackServerHost(t *testing.T) {
t.Parallel()
cases := []struct {
host string
want string
wantChanged bool
wantErr bool
}{
{host: "127.0.0.1", wantErr: false},
{host: "localhost", wantErr: false},
{host: "::1", wantErr: false},
{host: "0.0.0.0", wantErr: true},
{host: "192.168.1.10", wantErr: true},
{host: "127.0.0.1", want: "127.0.0.1", wantChanged: false, wantErr: false},
{host: "localhost", want: "127.0.0.1", wantChanged: true, wantErr: false},
{host: "::1", want: "127.0.0.1", wantChanged: true, wantErr: false},
{host: "0.0.0.0", want: "127.0.0.1", wantChanged: true, wantErr: false},
{host: "192.168.1.10", want: "127.0.0.1", wantChanged: true, wantErr: false},
}
for _, tc := range cases {
err := ensureLoopbackServerHost(tc.host)
got, changed, err := normalizeLoopbackServerHost(tc.host)
if tc.wantErr && err == nil {
t.Fatalf("expected error for host %q", tc.host)
}
if !tc.wantErr && err != nil {
t.Fatalf("unexpected error for host %q: %v", tc.host, err)
}
if got != tc.want {
t.Fatalf("unexpected normalized host for %q: got %q want %q", tc.host, got, tc.want)
}
if changed != tc.wantChanged {
t.Fatalf("unexpected changed flag for %q: got %t want %t", tc.host, changed, tc.wantChanged)
}
}
}

View File

@@ -148,10 +148,15 @@ func main() {
}
}
setConfigDefaults(cfg)
if err := ensureLoopbackServerHost(cfg.Server.Host); err != nil {
normalizedHost, changed, err := normalizeLoopbackServerHost(cfg.Server.Host)
if err != nil {
slog.Error("invalid server host", "host", cfg.Server.Host, "error", err)
os.Exit(1)
}
if changed {
slog.Warn("corrected server host to loopback", "from", cfg.Server.Host, "to", normalizedHost)
}
cfg.Server.Host = normalizedHost
if err := migrateConfigFileToRuntimeShape(resolvedConfigPath, cfg); err != nil {
slog.Error("failed to migrate config file format", "path", resolvedConfigPath, "error", err)
os.Exit(1)
@@ -334,21 +339,28 @@ func setConfigDefaults(cfg *config.Config) {
}
}
func ensureLoopbackServerHost(host string) error {
func normalizeLoopbackServerHost(host string) (string, bool, error) {
trimmed := strings.TrimSpace(host)
if trimmed == "" {
return fmt.Errorf("server.host must not be empty")
return "", false, fmt.Errorf("server.host must not be empty")
}
const loopbackHost = "127.0.0.1"
if trimmed == loopbackHost {
return loopbackHost, false, nil
}
if strings.EqualFold(trimmed, "localhost") {
return nil
return loopbackHost, true, nil
}
ip := net.ParseIP(strings.Trim(trimmed, "[]"))
if ip != nil && ip.IsLoopback() {
return nil
if ip != nil {
if ip.IsLoopback() || ip.IsUnspecified() {
return loopbackHost, trimmed != loopbackHost, nil
}
return loopbackHost, true, nil
}
return fmt.Errorf("QuoteForge local client must bind to localhost only")
return loopbackHost, true, nil
}
func vendorImportBodyLimit() int64 {
@@ -1490,6 +1502,8 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
project, err := projectService.Create(dbUsername, &req)
if err != nil {
switch {
case errors.Is(err, services.ErrReservedMainVariant):
respondError(c, http.StatusBadRequest, "invalid request", err)
case errors.Is(err, services.ErrProjectCodeExists):
respondError(c, http.StatusConflict, "conflict detected", err)
default:
@@ -1525,6 +1539,8 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
project, err := projectService.Update(c.Param("uuid"), dbUsername, &req)
if err != nil {
switch {
case errors.Is(err, services.ErrReservedMainVariant):
respondError(c, http.StatusBadRequest, "invalid request", err)
case errors.Is(err, services.ErrProjectCodeExists):
respondError(c, http.StatusConflict, "conflict detected", err)
case errors.Is(err, services.ErrProjectNotFound):

View File

@@ -611,6 +611,46 @@ func (l *LocalDB) SaveProject(project *LocalProject) error {
return l.db.Save(project).Error
}
// SaveProjectPreservingUpdatedAt stores a project without replacing UpdatedAt
// with the current local sync time.
func (l *LocalDB) SaveProjectPreservingUpdatedAt(project *LocalProject) error {
if project == nil {
return fmt.Errorf("project is nil")
}
if project.ID == 0 && strings.TrimSpace(project.UUID) != "" {
var existing LocalProject
err := l.db.Where("uuid = ?", project.UUID).First(&existing).Error
if err == nil {
project.ID = existing.ID
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
}
if project.ID == 0 {
return l.db.Create(project).Error
}
return l.db.Model(&LocalProject{}).
Where("id = ?", project.ID).
UpdateColumns(map[string]interface{}{
"uuid": project.UUID,
"server_id": project.ServerID,
"owner_username": project.OwnerUsername,
"code": project.Code,
"variant": project.Variant,
"name": project.Name,
"tracker_url": project.TrackerURL,
"is_active": project.IsActive,
"is_system": project.IsSystem,
"created_at": project.CreatedAt,
"updated_at": project.UpdatedAt,
"synced_at": project.SyncedAt,
"sync_status": project.SyncStatus,
}).Error
}
func (l *LocalDB) GetProjects(ownerUsername string, includeArchived bool) ([]LocalProject, error) {
var projects []LocalProject
query := l.db.Model(&LocalProject{}).Where("owner_username = ?", ownerUsername)

View File

@@ -0,0 +1,53 @@
package localdb
import (
"path/filepath"
"testing"
"time"
)
func TestSaveProjectPreservingUpdatedAtKeepsProvidedTimestamp(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "project_sync_timestamp.db")
local, err := New(dbPath)
if err != nil {
t.Fatalf("open localdb: %v", err)
}
t.Cleanup(func() { _ = local.Close() })
createdAt := time.Date(2026, 2, 1, 10, 0, 0, 0, time.UTC)
updatedAt := time.Date(2026, 2, 3, 12, 30, 0, 0, time.UTC)
project := &LocalProject{
UUID: "project-1",
OwnerUsername: "tester",
Code: "OPS-1",
Variant: "Lenovo",
IsActive: true,
CreatedAt: createdAt,
UpdatedAt: updatedAt,
SyncStatus: "synced",
}
if err := local.SaveProjectPreservingUpdatedAt(project); err != nil {
t.Fatalf("save project: %v", err)
}
syncedAt := time.Date(2026, 3, 16, 8, 45, 0, 0, time.UTC)
project.SyncedAt = &syncedAt
project.SyncStatus = "synced"
if err := local.SaveProjectPreservingUpdatedAt(project); err != nil {
t.Fatalf("save project second time: %v", err)
}
stored, err := local.GetProjectByUUID(project.UUID)
if err != nil {
t.Fatalf("get project: %v", err)
}
if !stored.UpdatedAt.Equal(updatedAt) {
t.Fatalf("updated_at changed during sync save: got %s want %s", stored.UpdatedAt, updatedAt)
}
if stored.SyncedAt == nil || !stored.SyncedAt.Equal(syncedAt) {
t.Fatalf("synced_at not updated correctly: got %+v want %s", stored.SyncedAt, syncedAt)
}
}

View File

@@ -20,6 +20,7 @@ var (
ErrProjectForbidden = errors.New("access to project forbidden")
ErrProjectCodeExists = errors.New("project code and variant already exist")
ErrCannotDeleteMainVariant = errors.New("cannot delete main variant")
ErrReservedMainVariant = errors.New("variant name 'main' is reserved")
)
type ProjectService struct {
@@ -63,6 +64,9 @@ func (s *ProjectService) Create(ownerUsername string, req *CreateProjectRequest)
return nil, fmt.Errorf("project code is required")
}
variant := strings.TrimSpace(req.Variant)
if err := validateProjectVariantName(variant); err != nil {
return nil, err
}
if err := s.ensureUniqueProjectCodeVariant("", code, variant); err != nil {
return nil, err
}
@@ -105,6 +109,9 @@ func (s *ProjectService) Update(projectUUID, ownerUsername string, req *UpdatePr
}
if req.Variant != nil {
localProject.Variant = strings.TrimSpace(*req.Variant)
if err := validateProjectVariantName(localProject.Variant); err != nil {
return nil, err
}
}
if err := s.ensureUniqueProjectCodeVariant(projectUUID, localProject.Code, localProject.Variant); err != nil {
return nil, err
@@ -166,6 +173,13 @@ func normalizeProjectVariant(variant string) string {
return strings.ToLower(strings.TrimSpace(variant))
}
func validateProjectVariantName(variant string) error {
if normalizeProjectVariant(variant) == "main" {
return ErrReservedMainVariant
}
return nil
}
func (s *ProjectService) Archive(projectUUID, ownerUsername string) error {
return s.setProjectActive(projectUUID, ownerUsername, false)
}

View File

@@ -0,0 +1,60 @@
package services
import (
"errors"
"path/filepath"
"testing"
"git.mchus.pro/mchus/quoteforge/internal/localdb"
)
func TestProjectServiceCreateRejectsReservedMainVariant(t *testing.T) {
local, err := newProjectTestLocalDB(t)
if err != nil {
t.Fatalf("open localdb: %v", err)
}
service := NewProjectService(local)
_, err = service.Create("tester", &CreateProjectRequest{
Code: "OPS-1",
Variant: "main",
})
if !errors.Is(err, ErrReservedMainVariant) {
t.Fatalf("expected ErrReservedMainVariant, got %v", err)
}
}
func TestProjectServiceUpdateRejectsReservedMainVariant(t *testing.T) {
local, err := newProjectTestLocalDB(t)
if err != nil {
t.Fatalf("open localdb: %v", err)
}
service := NewProjectService(local)
created, err := service.Create("tester", &CreateProjectRequest{
Code: "OPS-1",
Variant: "Lenovo",
})
if err != nil {
t.Fatalf("create project: %v", err)
}
mainName := "main"
_, err = service.Update(created.UUID, "tester", &UpdateProjectRequest{
Variant: &mainName,
})
if !errors.Is(err, ErrReservedMainVariant) {
t.Fatalf("expected ErrReservedMainVariant, got %v", err)
}
}
func newProjectTestLocalDB(t *testing.T) (*localdb.LocalDB, error) {
t.Helper()
dbPath := filepath.Join(t.TempDir(), "project_test.db")
local, err := localdb.New(dbPath)
if err != nil {
return nil, err
}
t.Cleanup(func() { _ = local.Close() })
return local, nil
}

View File

@@ -215,7 +215,7 @@ func (s *Service) ImportProjectsToLocal() (*ProjectImportResult, error) {
existing.SyncStatus = "synced"
existing.SyncedAt = &now
if err := s.localDB.SaveProject(existing); err != nil {
if err := s.localDB.SaveProjectPreservingUpdatedAt(existing); err != nil {
return nil, fmt.Errorf("saving local project %s: %w", project.UUID, err)
}
result.Updated++
@@ -225,7 +225,7 @@ func (s *Service) ImportProjectsToLocal() (*ProjectImportResult, error) {
localProject := localdb.ProjectToLocal(&project)
localProject.SyncStatus = "synced"
localProject.SyncedAt = &now
if err := s.localDB.SaveProject(localProject); err != nil {
if err := s.localDB.SaveProjectPreservingUpdatedAt(localProject); err != nil {
return nil, fmt.Errorf("saving local project %s: %w", project.UUID, err)
}
result.Imported++
@@ -1008,7 +1008,7 @@ func (s *Service) pushProjectChange(change *localdb.PendingChange) error {
localProject.SyncStatus = "synced"
now := time.Now()
localProject.SyncedAt = &now
_ = s.localDB.SaveProject(localProject)
_ = s.localDB.SaveProjectPreservingUpdatedAt(localProject)
}
return nil
@@ -1278,7 +1278,7 @@ func (s *Service) ensureConfigurationProject(mariaDB *gorm.DB, cfg *models.Confi
localProject.SyncStatus = "synced"
now := time.Now()
localProject.SyncedAt = &now
_ = s.localDB.SaveProject(localProject)
_ = s.localDB.SaveProjectPreservingUpdatedAt(localProject)
}
return nil
}

View File

@@ -0,0 +1,32 @@
# QuoteForge v1.5.4
Дата релиза: 2026-03-16
Тег: `v1.5.4`
## Ключевые изменения
- runtime автоматически нормализует `server.host` к `127.0.0.1` и переписывает некорректный локальный конфиг;
- добавлены действия с вариантом и унифицированы правила именования `_копия` для вариантов и конфигураций;
- исправлен CSV-экспорт прайсинговых таблиц в конфигураторе под Excel-совместимый формат;
- таблица проектов переработана: новая колонка даты, tooltip с деталями, отдельный автор, компактные действия и ссылка на трекер;
- sync больше не подменяет `updated_at` проектов временем синхронизации;
- добавлена одноразовая утилита `cmd/migrate_project_updated_at` для пересинхронизации `updated_at` проектов из MariaDB в локальную SQLite.
## Затронутые области
- `cmd/qfs/`;
- `cmd/migrate_project_updated_at/`;
- `internal/localdb/`;
- `internal/services/project.go`;
- `internal/services/sync/service.go`;
- `web/templates/index.html`;
- `web/templates/project_detail.html`;
- `web/templates/projects.html`;
- `web/templates/configs.html`;
- `bible-local/`.
## Совместимость
- схема данных не меняется;
- серверные SQL-миграции не требуются;
- для уже испорченных локальных дат проектов можно один раз запустить `go run ./cmd/migrate_project_updated_at -apply`.

View File

@@ -203,6 +203,8 @@ let projectsCache = [];
let projectNameByUUID = {};
let projectCodeByUUID = {};
let projectVariantByUUID = {};
let configProjectUUIDByUUID = {};
let configNameByUUID = {};
let pendingMoveConfigUUID = '';
let pendingMoveProjectCode = '';
let pendingCreateConfigName = '';
@@ -343,6 +345,45 @@ function findProjectByInput(input) {
return null;
}
async function resolveUniqueConfigName(baseName, projectUUID, excludeUUID) {
const cleanedBase = (baseName || '').trim();
if (!cleanedBase) {
return {error: 'Введите название'};
}
let configs = [];
if (projectUUID) {
const resp = await fetch('/api/projects/' + projectUUID + '/configs?status=all');
if (!resp.ok) {
return {error: 'Не удалось проверить конфигурации проекта'};
}
const data = await resp.json().catch(() => ({}));
configs = Array.isArray(data.configurations) ? data.configurations : [];
} else {
configs = Object.keys(configProjectUUIDByUUID)
.filter(uuid => !configProjectUUIDByUUID[uuid])
.map(uuid => ({uuid: uuid, name: configNameByUUID[uuid] || ''}));
}
const used = new Set(
configs
.filter(cfg => !excludeUUID || cfg.uuid !== excludeUUID)
.map(cfg => (cfg.name || '').trim().toLowerCase())
);
if (!used.has(cleanedBase.toLowerCase())) {
return {name: cleanedBase, changed: false};
}
let candidate = cleanedBase + '_копия';
let suffix = 2;
while (used.has(candidate.toLowerCase())) {
candidate = cleanedBase + '_копия' + suffix;
suffix++;
}
return {name: candidate, changed: true};
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
@@ -385,14 +426,23 @@ function closeRenameModal() {
async function renameConfig() {
const uuid = document.getElementById('rename-uuid').value;
const name = document.getElementById('rename-input').value.trim();
const rawName = document.getElementById('rename-input').value.trim();
if (!name) {
if (!rawName) {
alert('Введите название');
return;
}
try {
const result = await resolveUniqueConfigName(rawName, configProjectUUIDByUUID[uuid] || '', uuid);
if (result.error) {
alert(result.error);
return;
}
const name = result.name;
if (result.changed) {
document.getElementById('rename-input').value = name;
}
const resp = await fetch('/api/configs/' + uuid + '/rename', {
method: 'PATCH',
headers: {
@@ -416,7 +466,7 @@ async function renameConfig() {
function openCloneModal(uuid, currentName) {
document.getElementById('clone-uuid').value = uuid;
document.getElementById('clone-input').value = currentName + ' (копия)';
document.getElementById('clone-input').value = currentName + '_копия';
document.getElementById('clone-modal').classList.remove('hidden');
document.getElementById('clone-modal').classList.add('flex');
document.getElementById('clone-input').focus();
@@ -430,14 +480,23 @@ function closeCloneModal() {
async function cloneConfig() {
const uuid = document.getElementById('clone-uuid').value;
const name = document.getElementById('clone-input').value.trim();
const rawName = document.getElementById('clone-input').value.trim();
if (!name) {
if (!rawName) {
alert('Введите название');
return;
}
try {
const result = await resolveUniqueConfigName(rawName, configProjectUUIDByUUID[uuid] || '', uuid);
if (result.error) {
alert(result.error);
return;
}
const name = result.name;
if (result.changed) {
document.getElementById('clone-input').value = name;
}
const resp = await fetch('/api/configs/' + uuid + '/clone', {
method: 'POST',
headers: {
@@ -851,6 +910,12 @@ async function loadConfigs() {
}
const data = await resp.json();
configProjectUUIDByUUID = {};
configNameByUUID = {};
(data.configurations || []).forEach(cfg => {
configProjectUUIDByUUID[cfg.uuid] = cfg.project_uuid || '';
configNameByUUID[cfg.uuid] = cfg.name || '';
});
renderConfigs(data.configurations || []);
updatePagination(data.total);
} catch(e) {

View File

@@ -3947,29 +3947,36 @@ function exportPricingCSV(table) {
const rows = document.querySelectorAll(`#${bodyId} tr.${rowClass}`);
if (!rows.length) { showToast('Нет данных для экспорта', 'error'); return; }
const csvDelimiter = ';';
const cleanExportCell = value => {
const text = String(value || '').replace(/\s+/g, ' ').trim();
if (!text || text === '—') return text || '';
return text
.replace(/\s*\(.*\)$/, '')
.replace(/\s*\*+\s*$/, '')
.trim();
};
const csvEscape = v => {
if (v == null) return '';
const s = String(v).replace(/"/g, '""');
return /[,"\n]/.test(s) ? `"${s}"` : s;
return /[;"\n\r]/.test(s) ? `"${s}"` : s;
};
const headers = ['Lot', 'PN вендора', 'Описание', 'Кол-во', 'Estimate', 'Склад', 'Конкуренты', 'Ручная цена'];
const lines = [headers.map(csvEscape).join(',')];
const lines = [headers.map(csvEscape).join(csvDelimiter)];
rows.forEach(tr => {
const cells = tr.querySelectorAll('td');
const cols = [0,1,2,3,4,5,6,7].map(i => cells[i] ? cells[i].textContent.trim() : '');
lines.push(cols.map(csvEscape).join(','));
const cols = [0,1,2,3,4,5,6,7].map(i => cells[i] ? cleanExportCell(cells[i].textContent) : '');
lines.push(cols.map(csvEscape).join(csvDelimiter));
});
// Totals row
const tEst = document.getElementById(totalIds.est)?.textContent.trim() || '';
const tWh = document.getElementById(totalIds.wh)?.textContent.trim() || '';
const tComp = document.getElementById(totalIds.comp)?.textContent.trim() || '';
const tVendor = document.getElementById(totalIds.vendor)?.textContent.trim() || '';
// Strip % annotation from vendor total for CSV
const tVendorClean = tVendor.replace(/\s*\(.*\)$/, '').trim();
lines.push(['', '', '', 'Итого:', tEst, tWh, tComp, tVendorClean].map(csvEscape).join(','));
const tEst = cleanExportCell(document.getElementById(totalIds.est)?.textContent);
const tWh = cleanExportCell(document.getElementById(totalIds.wh)?.textContent);
const tComp = cleanExportCell(document.getElementById(totalIds.comp)?.textContent);
const tVendor = cleanExportCell(document.getElementById(totalIds.vendor)?.textContent);
lines.push(['', '', '', 'Итого:', tEst, tWh, tComp, tVendor].map(csvEscape).join(csvDelimiter));
const blob = new Blob(['\uFEFF' + lines.join('\r\n')], {type: 'text/csv;charset=utf-8;'});
const url = URL.createObjectURL(blob);

View File

@@ -29,23 +29,26 @@
<button onclick="openNewVariantModal()" class="inline-flex w-full sm:w-auto justify-center items-center px-3 py-1.5 text-sm font-medium bg-purple-600 text-white rounded-lg hover:bg-purple-700">
+ Вариант
</button>
<button onclick="openVariantActionModal()" class="inline-flex w-full sm:w-auto justify-center items-center px-3 py-1.5 text-sm font-medium bg-indigo-600 text-white rounded-lg hover:bg-indigo-700">
Действия с вариантом
</button>
</div>
</div>
</div>
<div id="action-buttons" class="mt-4 grid grid-cols-1 sm:grid-cols-6 gap-3">
<button onclick="openCreateModal()" class="py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium">
Новая конфигурация
+ Конфигурация
</button>
<button onclick="openVendorImportModal()" class="py-2 bg-amber-600 text-white rounded-lg hover:bg-amber-700 font-medium">
Импорт выгрузки вендора
</button>
<button onclick="openProjectSettingsModal()" class="py-2 bg-gray-700 text-white rounded-lg hover:bg-gray-800 font-medium">
Параметры
Импорт
</button>
<button onclick="openExportModal()" class="py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 font-medium">
Экспорт CSV
</button>
<button onclick="openProjectSettingsModal()" class="py-2 bg-gray-700 text-white rounded-lg hover:bg-gray-800 font-medium">
Параметры
</button>
<button id="delete-variant-btn" onclick="deleteVariant()" class="py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 font-medium hidden">
Удалить вариант
</button>
@@ -173,6 +176,34 @@
</div>
</div>
<div id="variant-action-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
<div class="bg-white rounded-lg shadow-xl w-full max-w-md mx-4 p-6">
<h2 class="text-xl font-semibold mb-4">Действия с вариантом</h2>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Название</label>
<input type="text" id="variant-action-name"
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500">
</div>
<label class="flex items-center gap-2 text-sm text-gray-700">
<input type="checkbox" id="variant-action-copy" class="rounded border-gray-300">
Создать копию
</label>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Код проекта</label>
<input type="text" id="variant-action-code"
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500">
</div>
<input type="hidden" id="variant-action-current-name">
<input type="hidden" id="variant-action-current-code">
</div>
<div class="flex justify-end space-x-3 mt-6">
<button onclick="closeVariantActionModal()" class="px-4 py-2 text-gray-600 hover:text-gray-800">Отмена</button>
<button onclick="saveVariantAction()" class="px-4 py-2 bg-indigo-600 text-white rounded hover:bg-indigo-700">Сохранить</button>
</div>
</div>
</div>
<div id="config-action-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>
@@ -540,6 +571,213 @@ function closeNewVariantModal() {
document.getElementById('new-variant-modal').classList.remove('flex');
}
function openVariantActionModal() {
if (!project) return;
const currentName = (project.variant || '').trim();
const currentCode = (project.code || '').trim();
document.getElementById('variant-action-current-name').value = currentName;
document.getElementById('variant-action-current-code').value = currentCode;
document.getElementById('variant-action-name').value = currentName;
document.getElementById('variant-action-code').value = currentCode;
document.getElementById('variant-action-copy').checked = false;
document.getElementById('variant-action-modal').classList.remove('hidden');
document.getElementById('variant-action-modal').classList.add('flex');
const nameInput = document.getElementById('variant-action-name');
nameInput.focus();
nameInput.select();
}
function closeVariantActionModal() {
document.getElementById('variant-action-modal').classList.add('hidden');
document.getElementById('variant-action-modal').classList.remove('flex');
}
function findUniqueVariantActionName(baseName, targetCode, excludeProjectUUID) {
const cleanedBase = (baseName || '').trim();
if (!cleanedBase || normalizeVariantLabel(cleanedBase).toLowerCase() === 'main') {
return {error: 'Имя варианта не должно быть пустым и не может быть main'};
}
const code = (targetCode || '').trim();
const used = new Set(
projectsCatalog
.filter(p => (p.code || '').trim().toLowerCase() === code.toLowerCase())
.filter(p => !excludeProjectUUID || p.uuid !== excludeProjectUUID)
.map(p => ((p.variant || '').trim()).toLowerCase())
);
if (!used.has(cleanedBase.toLowerCase())) {
return {name: cleanedBase, changed: false};
}
let candidate = cleanedBase + '_копия';
let suffix = 2;
while (used.has(candidate.toLowerCase())) {
candidate = cleanedBase + '_копия' + suffix;
suffix++;
}
return {name: candidate, changed: true};
}
async function resolveUniqueConfigActionName(baseName, targetProjectUUID, excludeConfigUUID) {
const cleanedBase = (baseName || '').trim();
if (!cleanedBase) {
return {error: 'Введите название'};
}
let configs = [];
if (targetProjectUUID === projectUUID) {
configs = Array.isArray(allConfigs) ? allConfigs : [];
} else {
const resp = await fetch('/api/projects/' + targetProjectUUID + '/configs?status=all');
if (!resp.ok) {
return {error: 'Не удалось проверить конфигурации целевого проекта'};
}
const data = await resp.json().catch(() => ({}));
configs = Array.isArray(data.configurations) ? data.configurations : [];
}
const used = new Set(
configs
.filter(cfg => !excludeConfigUUID || cfg.uuid !== excludeConfigUUID)
.map(cfg => (cfg.name || '').trim().toLowerCase())
)
if (!used.has(cleanedBase.toLowerCase())) {
return {name: cleanedBase, changed: false};
}
let candidate = cleanedBase + '_копия';
let suffix = 2;
while (used.has(candidate.toLowerCase())) {
candidate = cleanedBase + '_копия' + suffix;
suffix++;
}
return {name: candidate, changed: true};
}
async function cloneVariantConfigurations(targetProjectUUID) {
const listResp = await fetch('/api/projects/' + projectUUID + '/configs');
if (!listResp.ok) {
throw new Error('Не удалось загрузить конфигурации варианта');
}
const listData = await listResp.json().catch(() => ({}));
const configs = Array.isArray(listData.configurations) ? listData.configurations : [];
for (const cfg of configs) {
const cloneResp = await fetch('/api/projects/' + targetProjectUUID + '/configs/' + cfg.uuid + '/clone', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({name: cfg.name})
});
if (!cloneResp.ok) {
throw new Error('Не удалось скопировать конфигурацию «' + (cfg.name || 'без названия') + '»');
}
}
}
async function saveVariantAction() {
if (!project) return;
const notify = (message, type) => {
if (typeof showToast === 'function') {
showToast(message, type || 'success');
} else {
alert(message);
}
};
const currentName = document.getElementById('variant-action-current-name').value.trim();
const currentCode = document.getElementById('variant-action-current-code').value.trim();
const rawName = document.getElementById('variant-action-name').value.trim();
const code = document.getElementById('variant-action-code').value.trim();
const copy = document.getElementById('variant-action-copy').checked;
if (!code) {
notify('Введите код проекта', 'error');
return;
}
const uniqueNameResult = findUniqueVariantActionName(rawName, code, copy ? '' : projectUUID);
if (uniqueNameResult.error) {
notify(uniqueNameResult.error, 'error');
return;
}
const name = uniqueNameResult.name;
if (uniqueNameResult.changed) {
document.getElementById('variant-action-name').value = name;
notify('Имя варианта занято, использовано ' + name, 'success');
}
if (copy) {
const createResp = await fetch('/api/projects', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
code: code,
variant: name,
name: project.name || null,
tracker_url: (project.tracker_url || '').trim()
})
});
if (!createResp.ok) {
if (createResp.status === 400) {
notify('Имя варианта не может быть main', 'error');
return;
}
if (createResp.status === 409) {
notify('Вариант с таким кодом и значением уже существует', 'error');
return;
}
notify('Не удалось создать копию варианта', 'error');
return;
}
const created = await createResp.json().catch(() => null);
if (!created || !created.uuid) {
notify('Не удалось создать копию варианта', 'error');
return;
}
try {
await cloneVariantConfigurations(created.uuid);
} catch (err) {
notify(err.message || 'Вариант создан, но конфигурации не скопированы полностью', 'error');
window.location.href = '/projects/' + created.uuid;
return;
}
closeVariantActionModal();
notify('Копия варианта создана', 'success');
window.location.href = '/projects/' + created.uuid;
return;
}
const changed = name !== currentName || code !== currentCode;
if (!changed) {
closeVariantActionModal();
return;
}
const updateResp = await fetch('/api/projects/' + projectUUID, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({code: code, variant: name})
});
if (!updateResp.ok) {
if (updateResp.status === 400) {
notify('Имя варианта не может быть main', 'error');
return;
}
if (updateResp.status === 409) {
notify('Вариант с таким кодом и значением уже существует', 'error');
return;
}
notify('Не удалось сохранить вариант', 'error');
return;
}
closeVariantActionModal();
await loadProject();
await loadConfigs();
updateDeleteVariantButton();
notify('Вариант обновлён', 'success');
}
async function createNewVariant() {
if (!project) return;
const code = (project.code || '').trim();
@@ -864,12 +1102,22 @@ async function saveConfigAction() {
notify('Введите название', 'error');
return;
}
const uniqueNameResult = await resolveUniqueConfigActionName(name, targetProjectUUID, copy ? '' : uuid);
if (uniqueNameResult.error) {
notify(uniqueNameResult.error, 'error');
return;
}
const resolvedName = uniqueNameResult.name;
if (uniqueNameResult.changed) {
document.getElementById('config-action-name').value = resolvedName;
notify('Имя занято, использовано ' + resolvedName, 'success');
}
if (copy) {
const cloneResp = await fetch('/api/projects/' + targetProjectUUID + '/configs/' + uuid + '/clone', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({name: name})
body: JSON.stringify({name: resolvedName})
});
if (!cloneResp.ok) {
notify('Не удалось скопировать конфигурацию', 'error');
@@ -886,11 +1134,11 @@ async function saveConfigAction() {
}
let changed = false;
if (name !== currentName) {
if (resolvedName !== currentName) {
const renameResp = await fetch('/api/configs/' + uuid + '/rename', {
method: 'PATCH',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({name: name})
body: JSON.stringify({name: resolvedName})
});
if (!renameResp.ok) {
notify('Не удалось переименовать конфигурацию', 'error');
@@ -1016,6 +1264,7 @@ function updateDeleteVariantButton() {
document.getElementById('create-modal').addEventListener('click', function(e) { if (e.target === this) closeCreateModal(); });
document.getElementById('vendor-import-modal').addEventListener('click', function(e) { if (e.target === this) closeVendorImportModal(); });
document.getElementById('new-variant-modal').addEventListener('click', function(e) { if (e.target === this) closeNewVariantModal(); });
document.getElementById('variant-action-modal').addEventListener('click', function(e) { if (e.target === this) closeVariantActionModal(); });
document.getElementById('config-action-modal').addEventListener('click', function(e) { if (e.target === this) closeConfigActionModal(); });
document.getElementById('project-settings-modal').addEventListener('click', function(e) { if (e.target === this) closeProjectSettingsModal(); });
document.getElementById('config-action-project-input').addEventListener('input', function(e) {
@@ -1026,7 +1275,7 @@ document.getElementById('config-action-copy').addEventListener('change', functio
const currentName = document.getElementById('config-action-current-name').value;
const nameInput = document.getElementById('config-action-name');
if (e.target.checked && nameInput.value.trim() === currentName.trim()) {
nameInput.value = currentName + ' (копия)';
nameInput.value = currentName + '_копия';
}
syncActionModalMode();
});
@@ -1034,6 +1283,7 @@ document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
closeCreateModal();
closeVendorImportModal();
closeVariantActionModal();
closeConfigActionModal();
closeProjectSettingsModal();
}

View File

@@ -64,7 +64,7 @@ let status = 'active';
let projectsSearch = '';
let authorSearch = '';
let currentPage = 1;
let perPage = 10;
let perPage = 33;
let sortField = 'created_at';
let sortDir = 'desc';
let createProjectTrackerManuallyEdited = false;
@@ -114,21 +114,21 @@ function formatDateParts(value) {
};
}
function renderAuditCell(value, user) {
const parts = formatDateParts(value);
const safeUser = escapeHtml((user || '—').trim() || '—');
if (!parts) {
return '<div class="leading-tight">' +
'<div class="text-gray-400">—</div>' +
'<div class="text-gray-400">—</div>' +
'<div class="text-gray-500">@ ' + safeUser + '</div>' +
'</div>';
function formatISODate(value) {
if (!value) return '—';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '—';
return date.toISOString().slice(0, 10);
}
return '<div class="leading-tight whitespace-nowrap">' +
'<div>' + escapeHtml(parts.date) + '</div>' +
'<div class="text-gray-500">' + escapeHtml(parts.time) + '</div>' +
'<div class="text-gray-600">@ ' + safeUser + '</div>' +
'</div>';
function renderProjectDateCell(project) {
const updatedDate = formatISODate(project && project.updated_at);
const tooltip = [
'Создан: ' + formatDateTime(project && project.created_at),
'Изменен: ' + formatDateTime(project && project.updated_at),
'Автор: ' + ((project && project.owner_username) || '—')
].join('\n');
return '<div class="whitespace-nowrap text-gray-600 cursor-help" title="' + escapeHtml(tooltip) + '">' + escapeHtml(updatedDate) + '</div>';
}
function normalizeVariant(variant) {
@@ -141,11 +141,11 @@ function renderVariantChips(code, fallbackVariant, fallbackUUID) {
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 '<a href="' + href + '" class="inline-flex items-center px-1.5 py-px text-xs leading-5 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>';
return '<a href="' + href + '" class="inline-flex items-center px-1.5 py-px text-xs leading-5 rounded-full bg-gray-100 text-gray-700 hover:bg-gray-200 hover:text-gray-900">' + escapeHtml(v.label) + '</a>';
}).join(' ');
}
@@ -262,25 +262,25 @@ async function loadProjects() {
let html = '<div class="overflow-x-auto"><table class="w-full table-fixed min-w-[980px]">';
html += '<thead class="bg-gray-50">';
html += '<tr>';
html += '<th class="w-28 px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Код</th>';
html += '<th class="w-28 px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Дата</th>';
html += '<th class="w-32 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">Название';
if (sortField === 'name') {
html += sortDir === 'asc' ? ' <span>↑</span>' : ' <span>↓</span>';
}
html += '</button></th>';
html += '<th class="w-44 px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Создан</th>';
html += '<th class="w-44 px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Изменен</th>';
html += '<th class="w-36 px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Варианты</th>';
html += '<th class="w-36 px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Действия</th>';
html += '<th class="w-24 px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Автор</th>';
html += '<th class="w-56 px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Варианты</th>';
html += '<th class="w-14 px-2 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-2 py-2"></th>';
html += '<th class="px-4 py-2"></th>';
html += '<th class="px-4 py-2"><input id="projects-author-filter" type="text" value="' + escapeHtml(authorSearch) + '" placeholder="Фильтр автора" class="w-full px-2 py-1 border rounded text-xs focus:ring-1 focus:ring-blue-500 focus:border-blue-500"></th>';
html += '<th class="px-4 py-2"></th>';
html += '<th class="px-4 py-2"></th>';
html += '<th class="px-4 py-2"></th>';
html += '<th class="px-2 py-2"></th>';
html += '</tr>';
html += '</thead><tbody class="divide-y">';
@@ -292,36 +292,21 @@ async function loadProjects() {
html += '<tr class="hover:bg-gray-50">';
const displayName = p.name || '';
const createdBy = p.owner_username || '—';
const updatedBy = '—';
const variantChips = renderVariantChips(p.code, p.variant, p.uuid);
html += '<td class="px-4 py-3 text-sm font-medium align-top"><a class="inline-block max-w-full text-blue-600 hover:underline whitespace-nowrap" href="/projects/' + p.uuid + '">' + escapeHtml(p.code || '—') + '</a></td>';
html += '<td class="px-4 py-3 text-sm text-gray-700 align-top"><div class="truncate" title="' + escapeHtml(displayName) + '">' + escapeHtml(displayName || '—') + '</div></td>';
html += '<td class="px-4 py-3 text-sm text-gray-600 align-top">' + renderAuditCell(p.created_at, createdBy) + '</td>';
html += '<td class="px-4 py-3 text-sm text-gray-600 align-top">' + renderAuditCell(p.updated_at, updatedBy) + '</td>';
html += '<td class="px-4 py-3 text-sm align-top">' + renderProjectDateCell(p) + '</td>';
html += '<td class="px-4 py-3 text-sm font-medium align-top break-words"><a class="inline text-blue-600 hover:underline break-all whitespace-normal" href="/projects/' + p.uuid + '">' + escapeHtml(p.code || '—') + '</a></td>';
html += '<td class="px-4 py-3 text-sm text-gray-700 align-top break-words"><div class="whitespace-normal break-words" title="' + escapeHtml(displayName) + '">' + escapeHtml(displayName || '—') + '</div></td>';
html += '<td class="px-4 py-3 text-sm text-gray-600 align-top whitespace-nowrap">' + escapeHtml(createdBy) + '</td>';
html += '<td class="px-4 py-3 text-sm align-top"><div class="flex flex-wrap gap-1">' + variantChips + '</div></td>';
html += '<td class="px-4 py-3 text-sm text-right"><div class="inline-flex items-center gap-2">';
html += '<td class="px-2 py-3 text-sm text-right"><div class="inline-flex items-center justify-end gap-2">';
if (p.is_active) {
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(' + 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>';
if ((p.tracker_url || '').trim() !== '') {
html += '<a href="' + escapeHtml(p.tracker_url) + '" target="_blank" rel="noopener noreferrer" class="inline-flex items-center justify-center w-5 h-5 text-sky-700 hover:text-sky-900 font-semibold" title="Открыть в трекере">T</a>';
}
html += '<button onclick="archiveProject(\'' + p.uuid + '\')" class="text-red-700 hover:text-red-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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path></svg>';
html += '</button>';
html += '<button onclick="addConfigToProject(\'' + p.uuid + '\')" class="text-indigo-700 hover:text-indigo-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="M12 4v16m8-8H4"></path></svg>';
html += '</button>';
} else {
html += '<button onclick="reactivateProject(\'' + p.uuid + '\')" class="text-emerald-700 hover:text-emerald-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="M5 13l4 4L19 7"></path></svg>';
html += '</button>';
}
html += '</div></td>';
html += '</tr>';