Compare commits

...

2 Commits
v2.26 ... v2.27

Author SHA1 Message Date
Mikhail Chusavitin
50f0e4f76f feat: индикатор присутствия в конфигурациях (иконка глаза)
Открытые конфигурации фиксируются в локальном SQLite (app_settings) и
передаются на сервер через qt_client_schema_state.open_config_uuids при
каждом цикле синхронизации. Списки конфигураций обогащаются полем viewers,
в таблицах отображается иконка глаза с подсказкой при наличии других
пользователей, открывших эту конфигурацию.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-30 00:56:06 +03:00
Mikhail Chusavitin
9601619d1b docs: release notes v2.26
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 16:22:54 +03:00
8 changed files with 225 additions and 4 deletions

View File

@@ -996,8 +996,27 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
return
}
uuids := make([]string, len(cfgs))
for i, cfg := range cfgs {
uuids[i] = cfg.UUID
}
viewers, _ := syncService.ListActiveViewersByConfigUUIDs(uuids)
type cfgRow struct {
models.Configuration
Viewers []string `json:"viewers"`
}
rows := make([]cfgRow, len(cfgs))
for i, cfg := range cfgs {
v := viewers[cfg.UUID]
if v == nil {
v = []string{}
}
rows[i] = cfgRow{Configuration: cfg, Viewers: v}
}
c.JSON(http.StatusOK, gin.H{
"configurations": cfgs,
"configurations": rows,
"total": total,
"page": page,
"per_page": perPage,
@@ -1332,6 +1351,16 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
}
c.JSON(http.StatusOK, config)
})
configs.POST("/:uuid/presence", func(c *gin.Context) {
_ = local.AddOpenConfigUUID(c.Param("uuid"))
c.JSON(http.StatusOK, gin.H{"ok": true})
})
configs.DELETE("/:uuid/presence", func(c *gin.Context) {
_ = local.RemoveOpenConfigUUID(c.Param("uuid"))
c.JSON(http.StatusOK, gin.H{"ok": true})
})
}
projects := api.Group("/projects")
@@ -1672,8 +1701,32 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
}
return
}
projUUIDs := make([]string, len(result.Configs))
for i, cfg := range result.Configs {
projUUIDs[i] = cfg.UUID
}
projViewers, _ := syncService.ListActiveViewersByConfigUUIDs(projUUIDs)
type projCfgRow struct {
models.Configuration
Viewers []string `json:"viewers"`
}
projRows := make([]projCfgRow, len(result.Configs))
for i, cfg := range result.Configs {
v := projViewers[cfg.UUID]
if v == nil {
v = []string{}
}
projRows[i] = projCfgRow{Configuration: cfg, Viewers: v}
}
c.Header("X-Config-Status", status)
c.JSON(http.StatusOK, result)
c.JSON(http.StatusOK, gin.H{
"project_uuid": result.ProjectUUID,
"configurations": projRows,
"total": result.Total,
})
})
projects.PATCH("/:uuid/configs/reorder", func(c *gin.Context) {

View File

@@ -1235,6 +1235,67 @@ func (l *LocalDB) GetLastComponentSyncError() string {
}
const openConfigUUIDsKey = "open_config_uuids"
// GetOpenConfigUUIDs returns UUIDs of all configurations currently open in the configurator.
func (l *LocalDB) GetOpenConfigUUIDs() []string {
value, ok := l.getAppSettingValue(openConfigUUIDsKey)
if !ok || value == "" {
return nil
}
var uuids []string
if err := json.Unmarshal([]byte(value), &uuids); err != nil {
return nil
}
return uuids
}
// AddOpenConfigUUID records that a configuration is open in the configurator.
func (l *LocalDB) AddOpenConfigUUID(uuid string) error {
uuids := l.GetOpenConfigUUIDs()
for _, u := range uuids {
if u == uuid {
return nil
}
}
uuids = append(uuids, uuid)
raw, err := json.Marshal(uuids)
if err != nil {
return err
}
return l.db.Exec(`
INSERT INTO app_settings (key, value, updated_at)
VALUES (?, ?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
`, openConfigUUIDsKey, string(raw), time.Now().Format(time.RFC3339)).Error
}
// RemoveOpenConfigUUID records that a configuration is no longer open in the configurator.
func (l *LocalDB) RemoveOpenConfigUUID(uuid string) error {
uuids := l.GetOpenConfigUUIDs()
filtered := uuids[:0]
for _, u := range uuids {
if u != uuid {
filtered = append(filtered, u)
}
}
var raw []byte
var err error
if len(filtered) == 0 {
raw = []byte("[]")
} else {
raw, err = json.Marshal(filtered)
if err != nil {
return err
}
}
return l.db.Exec(`
INSERT INTO app_settings (key, value, updated_at)
VALUES (?, ?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
`, openConfigUUIDsKey, string(raw), time.Now().Format(time.RFC3339)).Error
}
// CountLocalPricelists returns the number of local pricelists
func (l *LocalDB) CountLocalPricelists() int64 {
var count int64

View File

@@ -249,6 +249,13 @@ func (s *Service) reportClientSchemaState(mariaDB *gorm.DB, checkedAt time.Time)
pricelistItemsCount := s.localDB.CountAllPricelistItems()
componentsCount := s.localDB.CountComponents()
dbSizeBytes := s.localDB.DBFileSizeBytes()
openConfigUUIDs := s.localDB.GetOpenConfigUUIDs()
var openConfigUUIDsJSON *string
if len(openConfigUUIDs) > 0 {
raw, _ := json.Marshal(openConfigUUIDs)
s := string(raw)
openConfigUUIDsJSON = &s
}
return mariaDB.Exec(`
INSERT INTO qt_client_schema_state (
username, hostname, app_version,
@@ -257,9 +264,10 @@ func (s *Service) reportClientSchemaState(mariaDB *gorm.DB, checkedAt time.Time)
estimate_pricelist_version, warehouse_pricelist_version, competitor_pricelist_version,
last_sync_error_code, last_sync_error_text,
local_pricelist_count, pricelist_items_count, components_count, db_size_bytes,
open_config_uuids,
last_checked_at, updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
app_version = VALUES(app_version),
last_sync_at = VALUES(last_sync_at),
@@ -277,6 +285,7 @@ func (s *Service) reportClientSchemaState(mariaDB *gorm.DB, checkedAt time.Time)
pricelist_items_count = VALUES(pricelist_items_count),
components_count = VALUES(components_count),
db_size_bytes = VALUES(db_size_bytes),
open_config_uuids = VALUES(open_config_uuids),
last_checked_at = VALUES(last_checked_at),
updated_at = VALUES(updated_at)
`, username, hostname, appmeta.Version(),
@@ -285,6 +294,7 @@ func (s *Service) reportClientSchemaState(mariaDB *gorm.DB, checkedAt time.Time)
estimateVersion, warehouseVersion, competitorVersion,
lastSyncErrorCode, lastSyncErrorText,
localPricelistCount, pricelistItemsCount, componentsCount, dbSizeBytes,
openConfigUUIDsJSON,
checkedAt, checkedAt).Error
}

View File

@@ -630,6 +630,56 @@ func (s *Service) backfillUsedPricelistItemCategories(pricelistRepo *repository.
}
}
// ListActiveViewersByConfigUUIDs returns a map of configUUID → []username for users
// who currently have those configs open (based on the last two sync cycles).
func (s *Service) ListActiveViewersByConfigUUIDs(uuids []string) (map[string][]string, error) {
if len(uuids) == 0 {
return map[string][]string{}, nil
}
mariaDB, err := s.getDB()
if err != nil || mariaDB == nil {
return map[string][]string{}, nil
}
selfUsername := strings.ToLower(strings.TrimSpace(s.localDB.GetDBUser()))
type row struct {
Username string `gorm:"column:username"`
OpenConfigJSON string `gorm:"column:open_config_uuids"`
}
var rows []row
if err := mariaDB.Raw(`
SELECT username, open_config_uuids
FROM qt_client_schema_state
WHERE open_config_uuids IS NOT NULL
AND open_config_uuids != '[]'
AND last_checked_at > NOW() - INTERVAL 10 MINUTE
`).Scan(&rows).Error; err != nil {
return map[string][]string{}, nil
}
wantSet := make(map[string]struct{}, len(uuids))
for _, u := range uuids {
wantSet[u] = struct{}{}
}
result := make(map[string][]string)
for _, r := range rows {
if strings.ToLower(strings.TrimSpace(r.Username)) == selfUsername {
continue
}
var openUUIDs []string
if err := json.Unmarshal([]byte(r.OpenConfigJSON), &openUUIDs); err != nil {
continue
}
for _, ou := range openUUIDs {
if _, ok := wantSet[ou]; ok {
result[ou] = append(result[ou], r.Username)
}
}
}
return result, nil
}
// ListUserSyncStatuses returns users who have recorded a client schema state check.
func (s *Service) ListUserSyncStatuses(onlineThreshold time.Duration) ([]UserSyncStatus, error) {
mariaDB, err := s.getDB()

View File

@@ -0,0 +1,17 @@
# QuoteForge v2.26
Дата релиза: 2026-06-29
Тег: `v2.26`
Предыдущий релиз: `v2.25`
## Ключевые изменения
- fix: лоты, отсутствующие в текущем прайслисте, больше не блокируют сохранение конфига и генерацию артикула — такие лоты просто пропускаются;
- fix: если прайслист конфига удалён с сервера, автоматически выбирается последний активный;
- refactor: удалён мёртвый код qt_lot_metadata;
## Запуск на macOS
Снимите карантинный атрибут через терминал: `xattr -d com.apple.quarantine /path/to/qfs-darwin-arm64`
После этого бинарник запустится без предупреждения Gatekeeper.

View File

@@ -242,6 +242,7 @@ function renderConfigs(configs) {
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Цена (за 1 шт)</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 += '<th class="px-2 py-3 w-8"></th>';
html += '<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Действия</th>';
html += '</tr></thead><tbody class="divide-y">';
@@ -298,6 +299,16 @@ function renderConfigs(configs) {
html += '<td class="px-4 py-3 text-sm text-gray-500">' + pricePerUnit + '</td>';
html += '<td class="px-4 py-3 text-sm text-gray-500">' + serverCount + '</td>';
html += '<td class="px-4 py-3 text-sm text-right">' + total + '</td>';
const viewers = c.viewers || [];
if (viewers.length > 0) {
const names = viewers.map(escapeHtml).join(', ');
html += '<td class="px-2 py-3 text-center w-8">';
html += '<span title="Открыта: ' + names + '" class="inline-flex items-center justify-center text-blue-500 cursor-default">';
html += '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path></svg>';
html += '</span></td>';
} else {
html += '<td class="px-2 py-3 w-8"></td>';
}
html += '<td class="px-4 py-3 text-sm text-right space-x-2">';
if (configStatusMode === 'archived') {
html += '<button onclick="reactivateConfig(\'' + c.uuid + '\')" class="text-emerald-600 hover:text-emerald-800" title="Восстановить">';

View File

@@ -985,6 +985,16 @@ document.addEventListener('DOMContentLoaded', async function() {
if (configUUID) {
loadVendorSpec(configUUID);
}
// Presence: announce that this config is open and keep renewing every 4 min
if (configUUID) {
const sendPresence = () => fetch('/api/configs/' + configUUID + '/presence', {method: 'POST'}).catch(() => {});
sendPresence();
setInterval(sendPresence, 4 * 60 * 1000);
window.addEventListener('beforeunload', () => {
fetch('/api/configs/' + configUUID + '/presence', {method: 'DELETE', keepalive: true}).catch(() => {});
});
}
});
async function loadAllComponents() {

View File

@@ -518,7 +518,16 @@ function renderConfigs(configs) {
html += '<td class="px-4 py-3 text-sm text-gray-500"><input type="number" min="1" value="' + serverCount + '" class="w-16 px-1 py-0.5 border rounded text-center text-sm" data-uuid="' + c.uuid + '" data-prev="' + serverCount + '" onchange="updateConfigServerCount(this)"></td>';
}
html += '<td class="px-4 py-3 text-sm text-right" data-total-uuid="' + c.uuid + '">' + formatMoneyNoDecimals(total) + '</td>';
html += '<td class="px-2 py-3 text-sm text-center text-gray-500 w-12">main</td>';
const projViewers = c.viewers || [];
if (projViewers.length > 0) {
const projNames = projViewers.map(escapeHtml).join(', ');
html += '<td class="px-2 py-3 text-center w-12">';
html += '<span title="Открыта: ' + projNames + '" class="inline-flex items-center justify-center text-blue-500 cursor-default">';
html += '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path></svg>';
html += '</span></td>';
} else {
html += '<td class="px-2 py-3 w-12"></td>';
}
html += '<td class="px-4 py-3 text-sm text-right whitespace-nowrap"><div class="inline-flex items-center justify-end gap-2">';
if (configStatusMode === 'archived') {
html += '<button onclick="reactivateConfig(\'' + c.uuid + '\')" class="text-emerald-600 hover:text-emerald-800" title="Восстановить">';