feat: server-driven configurator settings via qt_settings

Replaces hardcoded JS category filters and config-type buttons with
server-pushed settings synced from qt_settings (MariaDB) → local_qt_settings (SQLite).

- new table local_qt_settings (AutoMigrate) — synced after component sync
- GET /api/configurator-settings returns config_types, tab_config,
  always_visible_tabs, required_categories with hardcoded fallbacks
- new-config modal: type buttons rendered from server data (Сервер/СХД static fallback)
- configurator: TAB_CONFIG and category filter driven by server; required-category badge on tabs
- SyncQtSettings wired into SyncComponents and SyncAll handlers (non-fatal on old server)
- bible-local/server-contract-qt-settings.md — contract for server-side agent
- page titles: OFS → QFS

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Mikhail Chusavitin
2026-06-23 09:33:31 +03:00
parent c0588e9710
commit 4900cd073c
18 changed files with 646 additions and 64 deletions

View File

@@ -1,4 +1,4 @@
{{define "title"}}Ревизии - OFS{{end}}
{{define "title"}}QFS Ревизии{{end}}
{{define "content"}}
<div class="space-y-4">

View File

@@ -1,4 +1,4 @@
{{define "title"}}Мои конфигурации - OFS{{end}}
{{define "title"}}QFS Мои конфигурации{{end}}
{{define "content"}}
<div class="space-y-4">
@@ -55,12 +55,12 @@
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Тип оборудования</label>
<div class="inline-flex rounded-lg border border-gray-200 overflow-hidden w-full">
<button type="button" id="type-server-btn" onclick="setCreateType('server')"
<div id="config-type-buttons" class="inline-flex rounded-lg border border-gray-200 overflow-hidden w-full">
<button type="button" data-type="server" onclick="setCreateType('server')"
class="flex-1 py-2 text-sm font-medium bg-blue-600 text-white">
Сервер
</button>
<button type="button" id="type-storage-btn" onclick="setCreateType('storage')"
<button type="button" data-type="storage" onclick="setCreateType('storage')"
class="flex-1 py-2 text-sm font-medium bg-white text-gray-700 hover:bg-gray-50 border-l border-gray-200">
СХД
</button>
@@ -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 `<button type="button" data-type="${t.code}" onclick="setCreateType('${t.code}')"
class="flex-1 py-2 text-sm font-medium ${borderClass} bg-white text-gray-700 hover:bg-gray-50">
${t.name_ru || t.code}
</button>`;
}).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');

View File

@@ -1,4 +1,4 @@
{{define "title"}}OFS - Конфигуратор{{end}}
{{define "title"}}QFS Конфигуратор{{end}}
{{define "content"}}
<div class="space-y-4">
@@ -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<UPPER_CODE> of allowed categories (from server)
let alwaysVisibleTabsSet = null; // Set<tabKey> — null means use hardcoded fallback
let requiredCategoriesMap = {}; // configTypeCode → Set<UPPER_CODE> 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);

View File

@@ -1,4 +1,4 @@
{{define "title"}}OFS - Партномера{{end}}
{{define "title"}}QFS Партномера{{end}}
{{define "content"}}
<div class="space-y-4">

View File

@@ -1,4 +1,4 @@
{{define "title"}}Прайслист - OFS{{end}}
{{define "title"}}QFS Прайслист{{end}}
{{define "content"}}
<div class="space-y-6">

View File

@@ -1,4 +1,4 @@
{{define "title"}}Прайслисты - OFS{{end}}
{{define "title"}}QFS Прайслисты{{end}}
{{define "content"}}
<div class="space-y-6">

View File

@@ -1,4 +1,4 @@
{{define "title"}}Проект - OFS{{end}}
{{define "title"}}QFS Проект{{end}}
{{define "content"}}
<div class="space-y-4">

View File

@@ -1,4 +1,4 @@
{{define "title"}}Мои проекты - OFS{{end}}
{{define "title"}}QFS Мои проекты{{end}}
{{define "content"}}
<div class="space-y-4">