diff --git a/bible-local/02-architecture.md b/bible-local/02-architecture.md index 219f3cd..bd24cc6 100644 --- a/bible-local/02-architecture.md +++ b/bible-local/02-architecture.md @@ -116,6 +116,28 @@ Rules: - storage configurations use the same vendor_spec + PN→LOT + pricing flow as server configurations; - storage component categories map to existing tabs: `ENC`/`DKC`/`CTL` → Base, `HIC` → PCI (HIC-карты СХД; `HBA`/`NIC` — серверные, не смешивать), `SSD`/`HDD` → Storage (используют существующие серверные LOT), `ACC` → Accessories (используют существующие серверные LOT), `SW` → SW. - `DKC` = контроллерная полка (модель СХД + тип дисков + кол-во слотов + кол-во контроллеров); `CTL` = контроллер (кэш + встроенные порты); `ENC` = дисковая полка без контроллера. +- the available config types and their localized names flow from `qt_settings.config_types` on the server; + QF falls back to hardcoded "server/Сервер" and "storage/СХД" when the setting is absent. + +## Server-driven configurator settings (`qt_settings`) + +QF reads four settings from `qt_settings` (MariaDB) and caches them in `local_qt_settings` (SQLite). +They are synced during every component sync. See `bible-local/server-contract-qt-settings.md` for the +full contract and JSON schemas. + +| Setting key | Effect in QF | +|-------------|-------------| +| `config_types` | New-config modal buttons; category allowlist per config type | +| `tab_config` | Configurator tab structure, sections, singleSelect | +| `always_visible_tabs` | Which tabs are shown even when empty | +| `required_categories` | Per-config-type badge on tabs with unfilled required categories | + +Rules: +- sync runs after `SyncComponents`; failure is non-fatal (Warn log only); +- `local_qt_settings` is a read-only cache — never written by user actions; +- absent or unparseable settings: QF uses hardcoded fallbacks for that key only; +- `config_types[].categories` is an allowlist: a category absent from all types is shown everywhere; +- `qt_categories.name` and `qt_categories.name_ru` are not used by QF runtime; do not depend on them. ## Vendor BOM contract diff --git a/bible-local/03-database.md b/bible-local/03-database.md index 0437da4..2da1dd7 100644 --- a/bible-local/03-database.md +++ b/bible-local/03-database.md @@ -20,6 +20,7 @@ Main tables: | `connection_settings` | encrypted MariaDB connection settings | | `app_settings` | local app state | | `local_schema_migrations` | applied local migration markers | +| `local_qt_settings` | server-pushed configurator settings cache (from `qt_settings`) | Rules: - cache tables may be rebuilt if local migration recovery requires it; @@ -34,12 +35,13 @@ MariaDB is the central sync database (`RFQ_LOG`). Final schema as of 2026-04-15. ### QuoteForge tables (qt_*) Runtime read: -- `qt_categories` — pricelist categories +- `qt_categories` — pricelist categories (note: `name`/`name_ru` columns being removed; QF does not use them) - `qt_lot_metadata` — component metadata, price settings - `qt_pricelists` — pricelist headers (source: estimate / warehouse / competitor) - `qt_pricelist_items` — pricelist rows - `qt_partnumber_books` — partnumber book headers - `qt_partnumber_book_items` — PN→LOT catalog payload +- `qt_settings` — server-pushed configurator settings; schema managed by server-side agent (see `bible-local/server-contract-qt-settings.md`) Runtime read/write: - `qt_projects` — projects @@ -91,11 +93,26 @@ Full column reference as of 2026-03-21 (`RFQ_LOG` final schema). |--------|------|-------| | id | bigint UNSIGNED PK AUTO_INCREMENT | | | code | varchar(20) UNIQUE NOT NULL | | -| name | varchar(100) NOT NULL | | -| name_ru | varchar(100) | | +| name | varchar(100) NOT NULL | being removed; QF does not use at runtime | +| name_ru | varchar(100) | being removed; QF does not use at runtime | | display_order | bigint DEFAULT 0 | | | is_required | tinyint(1) DEFAULT 0 | | +### qt_settings +Managed by the server-side agent. QF has SELECT-only access. +See `bible-local/server-contract-qt-settings.md` for full schema and value formats. +| Column | Type | Notes | +|--------|------|-------| +| name | varchar(100) PK | setting key | +| value | TEXT NOT NULL | JSON-encoded value | + +### local_qt_settings (SQLite) +Read-only cache of `qt_settings`. Synced during component sync. +| Column | Type | Notes | +|--------|------|-------| +| name | text PK | setting key | +| value | text | JSON value as-is from server | + ### qt_client_schema_state PK: (username, hostname) | Column | Type | Notes | diff --git a/bible-local/server-contract-qt-settings.md b/bible-local/server-contract-qt-settings.md new file mode 100644 index 0000000..52c6eb1 --- /dev/null +++ b/bible-local/server-contract-qt-settings.md @@ -0,0 +1,165 @@ +# Server contract: qt_settings + +## Purpose + +`qt_settings` is a general-purpose key→JSON-value table that the price management +application uses to push configuration into QuoteForge clients. QF reads it during +component sync and caches the result in `local_qt_settings` (SQLite). + +## Required MariaDB changes (implemented by server-side agent) + +```sql +CREATE TABLE IF NOT EXISTS qt_settings ( + name VARCHAR(100) NOT NULL PRIMARY KEY, + value TEXT NOT NULL -- JSON-encoded value +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +GRANT SELECT ON RFQ_LOG.qt_settings TO 'qfs_user'@'%'; +``` + +## Settings consumed by QuoteForge + +All values are JSON. Missing or unparseable entries are silently skipped; QF +falls back to hardcoded defaults for each missing key. + +--- + +### `config_types` + +Defines the available device configuration types, their localized names, and the +category codes that are allowed for each type. QF uses this for: +- the new-config modal (button list + labels); +- the configurator's category filter per `config_type`. + +**Value format:** JSON array of objects. + +```json +[ + { + "code": "server", + "name_ru": "Сервер", + "display_order": 10, + "categories": [ + "MB","CPU","MEM","RAID", + "SSD","HDD","M2","EDSFF","HHHL", + "GPU","NIC","HCA","DPU","HBA", + "PSU","PS","ACC","RISERS","CARD","BB" + ] + }, + { + "code": "storage", + "name_ru": "СХД", + "display_order": 20, + "categories": [ + "DKC","CPU","MEM","PS", + "SSD","HDD","M2","EDSFF","HHHL", + "NIC","HBA","HCA","ACC","CARD" + ] + } +] +``` + +Fields: +| Field | Type | Description | +|-------|------|-------------| +| `code` | string | Identifier stored on `qt_configurations.config_type`. Must be stable. | +| `name_ru` | string | Display name in Russian for the QF UI. | +| `display_order` | int | Sort order for the modal button list. | +| `categories` | string[] | Allowlist of LOT category codes visible in this config type. A category absent from ALL entries is visible in all types. | + +--- + +### `tab_config` + +Defines the configurator tab layout: which tabs exist, which categories each tab +contains, optional sub-sections within a tab, and whether the tab uses +single-select mode. + +**Value format:** JSON array of tab objects (ordered — defines tab bar order). + +```json +[ + { + "key": "base", + "label": "Base", + "single_select": true, + "categories": ["MB","CPU","MEM","ENC","DKC","CTL"], + "sections": null + }, + { + "key": "storage", + "label": "Storage", + "single_select": false, + "categories": ["RAID","M2","SSD","HDD","EDSFF","HHHL"], + "sections": [ + { "title": "RAID Контроллеры", "categories": ["RAID"] }, + { "title": "Диски", "categories": ["M2","SSD","HDD","EDSFF","HHHL"] } + ] + }, + { + "key": "pci", + "label": "PCI", + "single_select": false, + "categories": ["GPU","DPU","NIC","HCA","HBA","HIC"], + "sections": [ + { "title": "GPU / DPU", "categories": ["GPU","DPU"] }, + { "title": "NIC / HCA", "categories": ["NIC","HCA"] }, + { "title": "HBA", "categories": ["HBA"] }, + { "title": "HIC", "categories": ["HIC"] } + ] + }, + { "key": "power", "label": "Power", "single_select": false, "categories": ["PS","PSU"] }, + { "key": "accessories", "label": "Accessories", "single_select": false, "categories": ["ACC","CARD"] }, + { "key": "sw", "label": "SW", "single_select": false, "categories": ["SW"] } +] +``` + +The QF frontend always appends an "other" tab for any categories not listed here. + +--- + +### `always_visible_tabs` + +Tab keys that are always shown in the configurator regardless of whether they +contain any items. Other tabs are hidden when empty. + +**Value format:** JSON string array. + +```json +["base", "storage", "pci"] +``` + +--- + +### `required_categories` + +Category codes that must have at least one LOT selected for a configuration to +be considered complete. Keyed by `config_type` code. QF uses this to show a +badge on the tab label when required categories are missing. + +**Value format:** JSON object mapping config_type code → string array. + +```json +{ + "server": ["CPU", "MEM", "BB"], + "storage": ["DKC", "CPU", "MEM"] +} +``` + +--- + +## Backward compatibility + +- If `qt_settings` does not exist (old server): QF logs `Warn` during sync and + leaves `local_qt_settings` empty. The frontend falls back to hardcoded defaults + for all four settings. No crash, no data loss. +- If a specific key is absent from `qt_settings`: QF falls back to the hardcoded + default for that key only. +- Old QF clients that do not know about `local_qt_settings` continue to use their + hardcoded JS constants unchanged. + +## Note on `qt_categories` + +`qt_categories.name` and `qt_categories.name_ru` are being removed. +QF runtime does not depend on them — `GetCategories` derives `Name` from the +category code string stored in `local_components`. diff --git a/cmd/qfs/main.go b/cmd/qfs/main.go index d31f873..bdc5e13 100644 --- a/cmd/qfs/main.go +++ b/cmd/qfs/main.go @@ -919,6 +919,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect // Categories (public) api.GET("/categories", componentHandler.GetCategories) + api.GET("/configurator-settings", componentHandler.GetConfiguratorSettings) // Quote (public) quote := api.Group("/quote") diff --git a/internal/handlers/component.go b/internal/handlers/component.go index 10af18e..0a5fa42 100644 --- a/internal/handlers/component.go +++ b/internal/handlers/component.go @@ -125,3 +125,102 @@ func (h *ComponentHandler) GetCategories(c *gin.Context) { c.JSON(http.StatusOK, models.DefaultCategories) } + +func (h *ComponentHandler) GetConfiguratorSettings(c *gin.Context) { + s, _ := h.localDB.GetConfiguratorSettings() + if s == nil { + s = &localdb.ConfiguratorSettings{} + } + + if len(s.ConfigTypes) == 0 { + s.ConfigTypes = defaultConfigTypes() + } + if len(s.TabConfig) == 0 { + s.TabConfig = defaultTabConfig() + } + if len(s.AlwaysVisibleTabs) == 0 { + s.AlwaysVisibleTabs = []string{"base", "storage", "pci"} + } + if len(s.RequiredCategories) == 0 { + s.RequiredCategories = map[string][]string{"server": {"CPU", "MEM", "BB"}} + } + + c.JSON(http.StatusOK, s) +} + +func defaultConfigTypes() []localdb.ConfigTypeDef { + return []localdb.ConfigTypeDef{ + { + Code: "server", + NameRu: "Сервер", + DisplayOrder: 10, + Categories: []string{ + "MB", "CPU", "MEM", "RAID", + "SSD", "HDD", "M2", "EDSFF", "HHHL", + "GPU", "NIC", "HCA", "DPU", "HBA", + "PSU", "PS", "ACC", "RISERS", "CARD", "BB", + }, + }, + { + Code: "storage", + NameRu: "СХД", + DisplayOrder: 20, + Categories: []string{ + "DKC", "CPU", "MEM", "PS", + "SSD", "HDD", "M2", "EDSFF", "HHHL", + "NIC", "HBA", "HCA", "ACC", "CARD", + }, + }, + } +} + +func defaultTabConfig() []localdb.TabDef { + return []localdb.TabDef{ + { + Key: "base", + Label: "Base", + SingleSelect: true, + Categories: []string{"MB", "CPU", "MEM", "ENC", "DKC", "CTL"}, + }, + { + Key: "storage", + Label: "Storage", + SingleSelect: false, + Categories: []string{"RAID", "M2", "SSD", "HDD", "EDSFF", "HHHL"}, + Sections: []localdb.TabSection{ + {Title: "RAID Контроллеры", Categories: []string{"RAID"}}, + {Title: "Диски", Categories: []string{"M2", "SSD", "HDD", "EDSFF", "HHHL"}}, + }, + }, + { + Key: "pci", + Label: "PCI", + SingleSelect: false, + Categories: []string{"GPU", "DPU", "NIC", "HCA", "HBA", "HIC"}, + Sections: []localdb.TabSection{ + {Title: "GPU / DPU", Categories: []string{"GPU", "DPU"}}, + {Title: "NIC / HCA", Categories: []string{"NIC", "HCA"}}, + {Title: "HBA", Categories: []string{"HBA"}}, + {Title: "HIC", Categories: []string{"HIC"}}, + }, + }, + { + Key: "power", + Label: "Power", + SingleSelect: false, + Categories: []string{"PS", "PSU"}, + }, + { + Key: "accessories", + Label: "Accessories", + SingleSelect: false, + Categories: []string{"ACC", "CARD"}, + }, + { + Key: "sw", + Label: "SW", + SingleSelect: false, + Categories: []string{"SW"}, + }, + } +} diff --git a/internal/handlers/sync.go b/internal/handlers/sync.go index 0bb7893..8560bc2 100644 --- a/internal/handlers/sync.go +++ b/internal/handlers/sync.go @@ -203,6 +203,10 @@ func (h *SyncHandler) SyncComponents(c *gin.Context) { _ = h.localDB.SetComponentSyncResult("ok", "", now) h.localDB.AppendSyncLog("components", "ok", "", result.TotalSynced, now, result.Duration.Milliseconds()) + if err := h.localDB.SyncQtSettings(mariaDB); err != nil { + slog.Warn("qt_settings sync failed", "error", err) + } + c.JSON(http.StatusOK, SyncResultResponse{ Success: true, Message: "Components synced successfully", @@ -339,6 +343,10 @@ func (h *SyncHandler) SyncAll(c *gin.Context) { h.localDB.AppendSyncLog("components", "ok", "", compResult.TotalSynced, compNow, compResult.Duration.Milliseconds()) componentsSynced = compResult.TotalSynced + if err := h.localDB.SyncQtSettings(mariaDB); err != nil { + slog.Warn("qt_settings sync failed", "error", err) + } + // Sync pricelists plNow := time.Now() pricelistsSynced, err = h.syncService.SyncPricelists() diff --git a/internal/localdb/localdb.go b/internal/localdb/localdb.go index ef9db0a..2c3bee7 100644 --- a/internal/localdb/localdb.go +++ b/internal/localdb/localdb.go @@ -230,6 +230,7 @@ func autoMigrateLocalSchema(db *gorm.DB) error { &PendingChange{}, &LocalPartnumberBook{}, &SyncLogEntry{}, + &LocalQtSetting{}, ) } diff --git a/internal/localdb/models.go b/internal/localdb/models.go index 77d868a..8862f84 100644 --- a/internal/localdb/models.go +++ b/internal/localdb/models.go @@ -356,3 +356,12 @@ func (v *VendorSpec) Scan(value interface{}) error { } return json.Unmarshal(bytes, v) } + +// LocalQtSetting caches server-pushed settings from qt_settings (MariaDB) into local SQLite. +// Synced during component sync. Each row is a JSON-valued setting identified by name. +type LocalQtSetting struct { + Name string `gorm:"primaryKey;size:100"` + Value string `gorm:"type:text"` +} + +func (LocalQtSetting) TableName() string { return "local_qt_settings" } diff --git a/internal/localdb/qt_settings.go b/internal/localdb/qt_settings.go new file mode 100644 index 0000000..5260e1f --- /dev/null +++ b/internal/localdb/qt_settings.go @@ -0,0 +1,122 @@ +package localdb + +import ( + "encoding/json" + "fmt" + "log/slog" + + "gorm.io/gorm" +) + +// ConfigTypeDef describes one device configuration type as synced from qt_settings. +type ConfigTypeDef struct { + Code string `json:"code"` + NameRu string `json:"name_ru"` + DisplayOrder int `json:"display_order"` + Categories []string `json:"categories"` +} + +// TabSection is a named sub-group of categories within a configurator tab. +type TabSection struct { + Title string `json:"title"` + Categories []string `json:"categories"` +} + +// TabDef describes one tab in the configurator as synced from qt_settings. +type TabDef struct { + Key string `json:"key"` + Label string `json:"label"` + SingleSelect bool `json:"single_select"` + Categories []string `json:"categories"` + Sections []TabSection `json:"sections,omitempty"` +} + +// ConfiguratorSettings holds all four server-driven settings consumed by the configurator. +// Fields are nil/empty when the corresponding qt_settings key is absent or unparseable; +// callers are expected to apply hardcoded fallbacks in that case. +type ConfiguratorSettings struct { + ConfigTypes []ConfigTypeDef `json:"config_types"` + TabConfig []TabDef `json:"tab_config"` + AlwaysVisibleTabs []string `json:"always_visible_tabs"` + RequiredCategories map[string][]string `json:"required_categories"` +} + +// SyncQtSettings reads all rows from qt_settings on MariaDB and replaces the +// local_qt_settings cache in a single SQLite transaction. Returns an error if +// the qt_settings table doesn't exist on the server (old server without the +// table) or on any query/write failure. +func (l *LocalDB) SyncQtSettings(mariaDB *gorm.DB) error { + var rows []LocalQtSetting + if err := mariaDB. + Table("qt_settings"). + Select("name, value"). + Find(&rows).Error; err != nil { + return fmt.Errorf("reading qt_settings from MariaDB: %w", err) + } + + return l.db.Transaction(func(tx *gorm.DB) error { + if err := tx.Exec("DELETE FROM local_qt_settings").Error; err != nil { + return fmt.Errorf("clearing local_qt_settings: %w", err) + } + if len(rows) == 0 { + return nil + } + if err := tx.Create(&rows).Error; err != nil { + return fmt.Errorf("inserting local_qt_settings: %w", err) + } + slog.Info("qt_settings synced", "count", len(rows)) + return nil + }) +} + +// GetQtSetting returns the raw JSON value for a named setting. +// found is false when the key does not exist. +func (l *LocalDB) GetQtSetting(name string) (value string, found bool, err error) { + var row LocalQtSetting + res := l.db.Where("name = ?", name).First(&row) + if res.Error != nil { + if res.Error == gorm.ErrRecordNotFound { + return "", false, nil + } + return "", false, res.Error + } + return row.Value, true, nil +} + +// GetConfiguratorSettings reads all four known settings from local_qt_settings and +// parses them. Any missing or unparseable key is left as nil/zero in the result; +// the caller must apply fallbacks. +func (l *LocalDB) GetConfiguratorSettings() (*ConfiguratorSettings, error) { + out := &ConfiguratorSettings{} + + keys := []string{"config_types", "tab_config", "always_visible_tabs", "required_categories"} + for _, key := range keys { + raw, found, err := l.GetQtSetting(key) + if err != nil { + return out, fmt.Errorf("reading setting %q: %w", key, err) + } + if !found || raw == "" { + continue + } + switch key { + case "config_types": + if err := json.Unmarshal([]byte(raw), &out.ConfigTypes); err != nil { + slog.Warn("failed to parse config_types setting", "error", err) + } + case "tab_config": + if err := json.Unmarshal([]byte(raw), &out.TabConfig); err != nil { + slog.Warn("failed to parse tab_config setting", "error", err) + } + case "always_visible_tabs": + if err := json.Unmarshal([]byte(raw), &out.AlwaysVisibleTabs); err != nil { + slog.Warn("failed to parse always_visible_tabs setting", "error", err) + } + case "required_categories": + if err := json.Unmarshal([]byte(raw), &out.RequiredCategories); err != nil { + slog.Warn("failed to parse required_categories setting", "error", err) + } + } + } + + return out, nil +} diff --git a/internal/models/qt_setting.go b/internal/models/qt_setting.go new file mode 100644 index 0000000..72800fb --- /dev/null +++ b/internal/models/qt_setting.go @@ -0,0 +1,10 @@ +package models + +// QtSetting is the MariaDB-side model for qt_settings. +// The table is managed by the server-side agent; QF only reads from it. +type QtSetting struct { + Name string `gorm:"primaryKey;size:100" json:"name"` + Value string `gorm:"type:text" json:"value"` +} + +func (QtSetting) TableName() string { return "qt_settings" } diff --git a/web/templates/config_revisions.html b/web/templates/config_revisions.html index 4d58a86..cf176e9 100644 --- a/web/templates/config_revisions.html +++ b/web/templates/config_revisions.html @@ -1,4 +1,4 @@ -{{define "title"}}Ревизии - OFS{{end}} +{{define "title"}}QFS Ревизии{{end}} {{define "content"}}
diff --git a/web/templates/configs.html b/web/templates/configs.html index 735553e..b9a7d6e 100644 --- a/web/templates/configs.html +++ b/web/templates/configs.html @@ -1,4 +1,4 @@ -{{define "title"}}Мои конфигурации - OFS{{end}} +{{define "title"}}QFS Мои конфигурации{{end}} {{define "content"}}
@@ -55,12 +55,12 @@
-
- - @@ -532,18 +532,51 @@ async function cloneConfig() { } let createConfigType = 'server'; +let _cfgSettings = null; + +async function loadCfgSettings() { + if (_cfgSettings) return _cfgSettings; + try { + const r = await fetch('/api/configurator-settings'); + if (r.ok) _cfgSettings = await r.json(); + } catch(e) { /* use hardcoded fallback */ } + return _cfgSettings; +} + +function renderConfigTypeButtons(types) { + if (!types || !types.length) return; + const el = document.getElementById('config-type-buttons'); + if (!el) return; + el.innerHTML = types + .sort((a, b) => (a.display_order || 0) - (b.display_order || 0)) + .map((t, i) => { + const borderClass = i > 0 ? 'border-l border-gray-200' : ''; + return ``; + }).join(''); + // activate first type + const firstCode = types[0].code; + createConfigType = firstCode; + setCreateType(firstCode); +} function setCreateType(type) { createConfigType = type; - document.getElementById('type-server-btn').className = 'flex-1 py-2 text-sm font-medium ' + - (type === 'server' ? 'bg-blue-600 text-white' : 'bg-white text-gray-700 hover:bg-gray-50 border-l border-gray-200'); - document.getElementById('type-storage-btn').className = 'flex-1 py-2 text-sm font-medium border-l border-gray-200 ' + - (type === 'storage' ? 'bg-blue-600 text-white' : 'bg-white text-gray-700 hover:bg-gray-50'); + document.querySelectorAll('#config-type-buttons button').forEach(btn => { + const active = btn.dataset.type === type; + btn.className = 'flex-1 py-2 text-sm font-medium ' + + (active + ? 'bg-blue-600 text-white border-l border-gray-200' + : 'bg-white text-gray-700 hover:bg-gray-50 border-l border-gray-200'); + }); } function openCreateModal() { createConfigType = 'server'; setCreateType('server'); + loadCfgSettings().then(s => renderConfigTypeButtons(s && s.config_types)); document.getElementById('opportunity-number').value = ''; document.getElementById('create-project-input').value = ''; document.getElementById('create-modal').classList.remove('hidden'); diff --git a/web/templates/index.html b/web/templates/index.html index f444d88..5e9392f 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -1,4 +1,4 @@ -{{define "title"}}OFS - Конфигуратор{{end}} +{{define "title"}}QFS Конфигуратор{{end}} {{define "content"}}
@@ -488,7 +488,7 @@ function updateConfigBreadcrumbs() { configEl.textContent = truncateBreadcrumbSpecName(fullConfigName); configEl.title = fullConfigName; versionEl.textContent = 'main'; - document.title = code + ' / ' + variant + ' / ' + fullConfigName + ' — OFS'; + document.title = code + ' / ' + variant + ' / ' + fullConfigName + ' — QFS'; const configNameLinkEl = document.getElementById('breadcrumb-config-name-link'); if (configNameLinkEl && configUUID) { configNameLinkEl.href = '/configs/' + configUUID + '/revisions'; @@ -504,6 +504,9 @@ let currentTab = 'base'; let allComponents = []; let cart = []; let categoryOrderMap = {}; // Category code -> display_order mapping +let configTypeCategoryMap = {}; // configTypeCode → Set of allowed categories (from server) +let alwaysVisibleTabsSet = null; // Set — null means use hardcoded fallback +let requiredCategoriesMap = {}; // configTypeCode → Set of required categories let autoSaveTimeout = null; // Timeout for debounced autosave let hasUnsavedChanges = false; let exitSaveStarted = false; @@ -783,6 +786,95 @@ async function loadCategoriesFromAPI() { } } +async function loadCfgSettings() { + if (typeof _cfgSettings !== 'undefined' && _cfgSettings) return _cfgSettings; + try { + const r = await fetch('/api/configurator-settings'); + if (r.ok) { + window._cfgSettings = await r.json(); + return window._cfgSettings; + } + } catch(e) { /* fallback to hardcoded */ } + return null; +} + +function applyServerSettings(settings) { + if (!settings) return; + + // config_types → category allowlist map + if (Array.isArray(settings.config_types) && settings.config_types.length) { + configTypeCategoryMap = {}; + settings.config_types.forEach(ct => { + if (ct.code && Array.isArray(ct.categories)) { + configTypeCategoryMap[ct.code] = new Set(ct.categories.map(c => c.toUpperCase())); + } + }); + } + + // tab_config → update TAB_CONFIG (preserve .other) + if (Array.isArray(settings.tab_config) && settings.tab_config.length) { + const otherTab = TAB_CONFIG.other; + TAB_CONFIG = {}; + settings.tab_config.forEach(tab => { + TAB_CONFIG[tab.key] = { + categories: Array.isArray(tab.categories) ? tab.categories : [], + singleSelect: !!tab.single_select, + label: tab.label || tab.key, + sections: tab.sections || undefined + }; + }); + TAB_CONFIG.other = otherTab || { categories: [], singleSelect: false, label: 'Other' }; + ASSIGNED_CATEGORIES = Object.values(TAB_CONFIG).flatMap(t => t.categories).map(c => c.toUpperCase()); + } + + // always_visible_tabs + if (Array.isArray(settings.always_visible_tabs) && settings.always_visible_tabs.length) { + alwaysVisibleTabsSet = new Set(settings.always_visible_tabs); + } + + // required_categories + if (settings.required_categories && typeof settings.required_categories === 'object') { + requiredCategoriesMap = {}; + Object.entries(settings.required_categories).forEach(([ct, codes]) => { + if (Array.isArray(codes)) { + requiredCategoriesMap[ct] = new Set(codes.map(c => c.toUpperCase())); + } + }); + } + + applyConfigTypeToTabs(); + updateTabVisibility(); + updateRequiredCategoryBadges(); +} + +function updateRequiredCategoryBadges() { + const required = requiredCategoriesMap[configType]; + if (!required || !required.size) return; + + // Build set of categories that have at least one cart item + const filledCategories = new Set( + cart.map(item => (item.category || getCategoryFromLotName(item.lot_name) || '').toUpperCase()) + ); + + // For each tab, check if it contains any required-but-unfilled category + Object.entries(TAB_CONFIG).forEach(([tabKey, tabCfg]) => { + const btn = document.querySelector(`[data-tab="${tabKey}"]`); + if (!btn) return; + const tabCategories = (tabCfg.categories || []).map(c => c.toUpperCase()); + const hasUnfilled = tabCategories.some(cat => required.has(cat) && !filledCategories.has(cat)); + const badge = btn.querySelector('.required-badge'); + if (hasUnfilled) { + if (!badge) { + const dot = document.createElement('span'); + dot.className = 'required-badge inline-block w-1.5 h-1.5 bg-orange-400 rounded-full ml-1 align-middle'; + btn.appendChild(dot); + } + } else if (badge) { + badge.remove(); + } + }); +} + // Initialize document.addEventListener('DOMContentLoaded', async function() { // RBAC disabled - no token check required @@ -791,8 +883,9 @@ document.addEventListener('DOMContentLoaded', async function() { return; } - // Load categories in background (defaults are usable immediately). + // Load categories and configurator settings in background (defaults are usable immediately). const categoriesPromise = loadCategoriesFromAPI().catch(() => {}); + loadCfgSettings().then(s => applyServerSettings(s)).catch(() => {}); try { const resp = await fetch('/api/configs/' + configUUID); @@ -1160,60 +1253,60 @@ function switchTab(tab) { renderTab(); } +// Hardcoded fallback constants — used only when server has not provided config_types data const ALWAYS_VISIBLE_TABS = new Set(['base', 'storage', 'pci']); - -// Storage-only categories — hidden for server configs const STORAGE_ONLY_BASE_CATEGORIES = ['DKC', 'CTL', 'ENC']; -// Server-only categories — hidden for storage configs -const SERVER_ONLY_BASE_CATEGORIES = ['MB', 'CPU', 'MEM']; +const SERVER_ONLY_BASE_CATEGORIES = ['MB', 'CPU', 'MEM']; const STORAGE_HIDDEN_STORAGE_CATEGORIES = ['RAID']; const STORAGE_HIDDEN_PCI_CATEGORIES = ['GPU', 'DPU']; const STORAGE_HIDDEN_POWER_CATEGORIES = ['PS', 'PSU']; +function isCategoryVisibleForConfigType(code, cfgType) { + const allowed = configTypeCategoryMap[cfgType]; + if (!allowed || allowed.size === 0) return _hardcodedCategoryVisible(code, cfgType); + return allowed.has(code.toUpperCase()); +} + +function _hardcodedCategoryVisible(code, cfgType) { + if (cfgType === 'storage') { + if (STORAGE_ONLY_BASE_CATEGORIES.includes(code)) return true; + if (SERVER_ONLY_BASE_CATEGORIES.includes(code)) return false; + if (STORAGE_HIDDEN_STORAGE_CATEGORIES.includes(code)) return false; + if (STORAGE_HIDDEN_PCI_CATEGORIES.includes(code)) return false; + if (STORAGE_HIDDEN_POWER_CATEGORIES.includes(code)) return false; + } else { + if (STORAGE_ONLY_BASE_CATEGORIES.includes(code)) return false; + } + return true; +} + +function _effectiveAlwaysVisibleTabs() { + return alwaysVisibleTabsSet || ALWAYS_VISIBLE_TABS; +} + function applyConfigTypeToTabs() { - const baseCategories = ['MB', 'CPU', 'MEM', 'DKC', 'CTL', 'ENC']; - const storageCategories = ['RAID', 'M2', 'SSD', 'HDD', 'EDSFF', 'HHHL']; - const storageSections = [ - { title: 'RAID Контроллеры', categories: ['RAID'] }, - { title: 'Диски', categories: ['M2', 'SSD', 'HDD', 'EDSFF', 'HHHL'] } - ]; - const pciCategories = ['GPU', 'DPU', 'NIC', 'HCA', 'HBA', 'HIC']; - const pciSections = [ - { title: 'GPU / DPU', categories: ['GPU', 'DPU'] }, - { title: 'NIC / HCA', categories: ['NIC', 'HCA'] }, - { title: 'HBA', categories: ['HBA'] }, - { title: 'HIC', categories: ['HIC'] } - ]; - const powerCategories = ['PS', 'PSU']; + // Filter each tab's categories by visibility for current configType. + // Uses server-driven allowlists when available; falls back to hardcoded constants. + Object.keys(TAB_CONFIG).forEach(tabKey => { + if (tabKey === 'other') return; + const tab = TAB_CONFIG[tabKey]; + if (!tab || !Array.isArray(tab.categories)) return; - TAB_CONFIG.base.categories = baseCategories.filter(c => { - if (configType === 'storage') { - return !SERVER_ONLY_BASE_CATEGORIES.includes(c); - } - return !STORAGE_ONLY_BASE_CATEGORIES.includes(c); - }); + // Snapshot the full category list for this tab (stored in _allCategories if not yet saved) + if (!tab._allCategories) tab._allCategories = [...tab.categories]; - TAB_CONFIG.storage.categories = storageCategories.filter(c => { - return configType === 'storage' ? !STORAGE_HIDDEN_STORAGE_CATEGORIES.includes(c) : true; - }); - TAB_CONFIG.storage.sections = storageSections.filter(section => { - if (configType === 'storage') { - return !section.categories.every(cat => STORAGE_HIDDEN_STORAGE_CATEGORIES.includes(cat)); - } - return true; - }); + tab.categories = tab._allCategories.filter(c => isCategoryVisibleForConfigType(c, configType)); - TAB_CONFIG.pci.categories = pciCategories.filter(c => { - return configType === 'storage' ? !STORAGE_HIDDEN_PCI_CATEGORIES.includes(c) : c !== 'HIC'; - }); - TAB_CONFIG.pci.sections = pciSections.filter(section => { - if (configType === 'storage') { - return !section.categories.every(cat => STORAGE_HIDDEN_PCI_CATEGORIES.includes(cat)); + if (Array.isArray(tab._allSections || tab.sections)) { + const allSections = tab._allSections || tab.sections; + if (!tab._allSections) tab._allSections = allSections.map(s => ({ ...s, categories: [...s.categories] })); + tab.sections = tab._allSections + .map(section => ({ + ...section, + categories: section.categories.filter(c => isCategoryVisibleForConfigType(c, configType)) + })) + .filter(section => section.categories.length > 0); } - return section.title !== 'HIC'; - }); - TAB_CONFIG.power.categories = powerCategories.filter(c => { - return configType === 'storage' ? !STORAGE_HIDDEN_POWER_CATEGORIES.includes(c) : true; }); // Rebuild assigned categories index @@ -1223,8 +1316,9 @@ function applyConfigTypeToTabs() { } function updateTabVisibility() { + const visibleTabs = _effectiveAlwaysVisibleTabs(); for (const tabId of Object.keys(TAB_CONFIG)) { - if (ALWAYS_VISIBLE_TABS.has(tabId)) continue; + if (visibleTabs.has(tabId)) continue; const btn = document.querySelector(`[data-tab="${tabId}"]`); if (!btn) continue; const hasComponents = getComponentsForTab(tabId).length > 0; @@ -2151,6 +2245,7 @@ function removeFromCart(lotName) { function updateCartUI() { updateTabVisibility(); + updateRequiredCategoryBadges(); window._currentCart = cart; // expose for BOM/Pricing tabs const total = cart.reduce((sum, item) => sum + (getDisplayPrice(item) * item.quantity), 0); document.getElementById('cart-total').textContent = formatMoney(total); diff --git a/web/templates/partnumber_books.html b/web/templates/partnumber_books.html index 08d2179..b4e347d 100644 --- a/web/templates/partnumber_books.html +++ b/web/templates/partnumber_books.html @@ -1,4 +1,4 @@ -{{define "title"}}OFS - Партномера{{end}} +{{define "title"}}QFS Партномера{{end}} {{define "content"}}
diff --git a/web/templates/pricelist_detail.html b/web/templates/pricelist_detail.html index 7a3441e..6a98e10 100644 --- a/web/templates/pricelist_detail.html +++ b/web/templates/pricelist_detail.html @@ -1,4 +1,4 @@ -{{define "title"}}Прайслист - OFS{{end}} +{{define "title"}}QFS Прайслист{{end}} {{define "content"}}
diff --git a/web/templates/pricelists.html b/web/templates/pricelists.html index 23658fa..08e9a59 100644 --- a/web/templates/pricelists.html +++ b/web/templates/pricelists.html @@ -1,4 +1,4 @@ -{{define "title"}}Прайслисты - OFS{{end}} +{{define "title"}}QFS Прайслисты{{end}} {{define "content"}}
diff --git a/web/templates/project_detail.html b/web/templates/project_detail.html index 1a1f4f4..551bff8 100644 --- a/web/templates/project_detail.html +++ b/web/templates/project_detail.html @@ -1,4 +1,4 @@ -{{define "title"}}Проект - OFS{{end}} +{{define "title"}}QFS Проект{{end}} {{define "content"}}
diff --git a/web/templates/projects.html b/web/templates/projects.html index c585fbd..ecd1ea8 100644 --- a/web/templates/projects.html +++ b/web/templates/projects.html @@ -1,4 +1,4 @@ -{{define "title"}}Мои проекты - OFS{{end}} +{{define "title"}}QFS Мои проекты{{end}} {{define "content"}}