diff --git a/internal/localdb/components.go b/internal/localdb/components.go
index fbd0077..41363d5 100644
--- a/internal/localdb/components.go
+++ b/internal/localdb/components.go
@@ -28,8 +28,9 @@ type ComponentSyncResult struct {
func (l *LocalDB) SyncComponents(mariaDB *gorm.DB) (*ComponentSyncResult, error) {
startTime := time.Now()
- // Query to join lot with qt_lot_metadata (metadata only, no pricing)
- // Use LEFT JOIN to include lots without metadata
+ // Build the component catalog from every runtime source of LOT names.
+ // Storage lots may exist in qt_lot_metadata / qt_pricelist_items before they appear in lot,
+ // so the sync cannot start from lot alone.
type componentRow struct {
LotName string
LotDescription string
@@ -40,15 +41,29 @@ func (l *LocalDB) SyncComponents(mariaDB *gorm.DB) (*ComponentSyncResult, error)
var rows []componentRow
err := mariaDB.Raw(`
SELECT
- l.lot_name,
- l.lot_description,
- COALESCE(c.code, SUBSTRING_INDEX(l.lot_name, '_', 1)) as category,
- m.model
- FROM lot l
- LEFT JOIN qt_lot_metadata m ON l.lot_name = m.lot_name
+ src.lot_name,
+ COALESCE(MAX(NULLIF(TRIM(l.lot_description), '')), '') AS lot_description,
+ COALESCE(
+ MAX(NULLIF(TRIM(c.code), '')),
+ MAX(NULLIF(TRIM(l.lot_category), '')),
+ SUBSTRING_INDEX(src.lot_name, '_', 1)
+ ) AS category,
+ MAX(NULLIF(TRIM(m.model), '')) AS model
+ FROM (
+ SELECT lot_name FROM lot
+ UNION
+ SELECT lot_name FROM qt_lot_metadata
+ WHERE is_hidden = FALSE OR is_hidden IS NULL
+ UNION
+ SELECT lot_name FROM qt_pricelist_items
+ ) src
+ LEFT JOIN lot l ON l.lot_name = src.lot_name
+ LEFT JOIN qt_lot_metadata m
+ ON m.lot_name = src.lot_name
+ AND (m.is_hidden = FALSE OR m.is_hidden IS NULL)
LEFT JOIN qt_categories c ON m.category_id = c.id
- WHERE m.is_hidden = FALSE OR m.is_hidden IS NULL
- ORDER BY l.lot_name
+ GROUP BY src.lot_name
+ ORDER BY src.lot_name
`).Scan(&rows).Error
if err != nil {
return nil, fmt.Errorf("querying components from MariaDB: %w", err)
diff --git a/web/templates/index.html b/web/templates/index.html
index 95dd6dc..882fd17 100644
--- a/web/templates/index.html
+++ b/web/templates/index.html
@@ -378,7 +378,6 @@ let TAB_CONFIG = {
singleSelect: false,
label: 'Storage',
sections: [
- { title: 'RAID Контроллеры', categories: ['RAID'] },
{ title: 'Диски', categories: ['M2', 'SSD', 'HDD', 'EDSFF', 'HHHL'] }
]
},
@@ -837,6 +836,7 @@ document.addEventListener('DOMContentLoaded', async function() {
serverModelForQuote = config.server_model || '';
supportCode = config.support_code || '';
currentArticle = config.article || '';
+ restorePricingStateFromNotes(config.notes || '');
// Restore custom price if saved
if (config.custom_price) {
@@ -1158,9 +1158,16 @@ const ALWAYS_VISIBLE_TABS = new Set(['base', 'storage', 'pci']);
const STORAGE_ONLY_BASE_CATEGORIES = ['DKC', 'CTL', 'ENC'];
// Server-only categories — hidden for storage configs
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 applyConfigTypeToTabs() {
const baseCategories = ['MB', 'CPU', 'MEM', 'DKC', 'CTL', 'ENC'];
+ const storageCategories = ['RAID', 'M2', 'SSD', 'HDD', 'EDSFF', 'HHHL'];
+ const storageSections = [
+ { title: 'Диски', categories: ['M2', 'SSD', 'HDD', 'EDSFF', 'HHHL'] }
+ ];
const pciCategories = ['GPU', 'DPU', 'NIC', 'HCA', 'HBA', 'HIC'];
const pciSections = [
{ title: 'GPU / DPU', categories: ['GPU', 'DPU'] },
@@ -1168,6 +1175,7 @@ function applyConfigTypeToTabs() {
{ title: 'HBA', categories: ['HBA'] },
{ title: 'HIC', categories: ['HIC'] }
];
+ const powerCategories = ['PS', 'PSU'];
TAB_CONFIG.base.categories = baseCategories.filter(c => {
if (configType === 'storage') {
@@ -1176,11 +1184,22 @@ function applyConfigTypeToTabs() {
return !STORAGE_ONLY_BASE_CATEGORIES.includes(c);
});
+ TAB_CONFIG.storage.categories = storageCategories.filter(c => {
+ return configType === 'storage' ? !STORAGE_HIDDEN_STORAGE_CATEGORIES.includes(c) : true;
+ });
+ TAB_CONFIG.storage.sections = storageSections;
+
TAB_CONFIG.pci.categories = pciCategories.filter(c => {
- return configType === 'storage' ? true : c !== 'HIC';
+ return configType === 'storage' ? !STORAGE_HIDDEN_PCI_CATEGORIES.includes(c) : c !== 'HIC';
});
TAB_CONFIG.pci.sections = pciSections.filter(section => {
- return configType === 'storage' ? true : section.title !== 'HIC';
+ if (configType === 'storage') {
+ return !section.categories.every(cat => STORAGE_HIDDEN_PCI_CATEGORIES.includes(cat));
+ }
+ 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
@@ -1243,7 +1262,7 @@ function renderSingleSelectTab(categories) {
if (currentTab === 'base') {
html += `
-
+
@@ -2096,6 +2115,58 @@ function getCurrentArticle() {
return currentArticle || '';
}
+function buildPricingState() {
+ const buyCustom = parseDecimalInput(document.getElementById('pricing-custom-price-buy')?.value || '');
+ const saleUplift = parseDecimalInput(document.getElementById('pricing-uplift-sale')?.value || '');
+ const saleCustom = parseDecimalInput(document.getElementById('pricing-custom-price-sale')?.value || '');
+
+ return {
+ buy_custom_price: buyCustom > 0 ? buyCustom : null,
+ sale_uplift: saleUplift > 0 ? saleUplift : null,
+ sale_custom_price: saleCustom > 0 ? saleCustom : null,
+ };
+}
+
+function serializeConfigNotes() {
+ return JSON.stringify({
+ pricing_ui: buildPricingState()
+ });
+}
+
+function restorePricingStateFromNotes(notesRaw) {
+ if (!notesRaw) return;
+ let parsed;
+ try {
+ parsed = JSON.parse(notesRaw);
+ } catch (_) {
+ return;
+ }
+
+ const pricing = parsed?.pricing_ui;
+ if (!pricing || typeof pricing !== 'object') return;
+
+ const buyInput = document.getElementById('pricing-custom-price-buy');
+ if (buyInput) {
+ buyInput.value = typeof pricing.buy_custom_price === 'number' && pricing.buy_custom_price > 0
+ ? pricing.buy_custom_price.toFixed(2)
+ : '';
+ }
+
+ const upliftInput = document.getElementById('pricing-uplift-sale');
+ if (upliftInput) {
+ upliftInput.value = typeof pricing.sale_uplift === 'number' && pricing.sale_uplift > 0
+ ? formatUpliftInput(pricing.sale_uplift)
+ : '';
+ }
+
+ const saleInput = document.getElementById('pricing-custom-price-sale');
+ if (saleInput) {
+ saleInput.value = typeof pricing.sale_custom_price === 'number' && pricing.sale_custom_price > 0
+ ? pricing.sale_custom_price.toFixed(2)
+ : '';
+ }
+}
+
function getAutosaveStorageKey() {
return `qf_config_autosave_${configUUID || 'default'}`;
}
@@ -2109,7 +2180,7 @@ function buildSavePayload() {
name: configName,
items: cart,
custom_price: customPrice,
- notes: '',
+ notes: serializeConfigNotes(),
server_count: serverCount,
server_model: serverModelForQuote,
support_code: supportCode,
@@ -2588,66 +2659,67 @@ async function refreshPrices() {
return;
}
+ const refreshBtn = document.getElementById('refresh-prices-btn');
+ const previousLabel = refreshBtn ? refreshBtn.textContent : '';
+
try {
- const refreshPayload = {};
- if (selectedPricelistIds.estimate) {
- refreshPayload.pricelist_id = selectedPricelistIds.estimate;
+ if (refreshBtn) {
+ refreshBtn.disabled = true;
+ refreshBtn.textContent = 'Обновление...';
+ refreshBtn.className = 'px-4 py-2 bg-gray-300 text-gray-500 rounded cursor-not-allowed';
}
- const resp = await fetch('/api/configs/' + configUUID + '/refresh-prices', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(refreshPayload)
+
+ const componentSyncResp = await fetch('/api/sync/components', { method: 'POST' });
+ if (!componentSyncResp.ok) {
+ throw new Error('component sync failed');
+ }
+
+ const pricelistSyncResp = await fetch('/api/sync/pricelists', { method: 'POST' });
+ if (!pricelistSyncResp.ok) {
+ throw new Error('pricelist sync failed');
+ }
+
+ await Promise.all([
+ loadActivePricelists(true),
+ loadAllComponents()
+ ]);
+
+ ['estimate', 'warehouse', 'competitor'].forEach(source => {
+ const latest = activePricelistsBySource[source]?.[0];
+ if (latest && latest.id) {
+ selectedPricelistIds[source] = Number(latest.id);
+ resolvedAutoPricelistIds[source] = null;
+ }
});
- if (!resp.ok) {
- showToast('Ошибка обновления цен', 'error');
- return;
- }
+ syncPriceSettingsControls();
+ renderPricelistSettingsSummary();
+ persistLocalPriceSettings();
- const config = await resp.json();
-
- // Update cart with new prices
- if (config.items && config.items.length > 0) {
- cart = config.items.map(item => ({
- lot_name: item.lot_name,
- quantity: item.quantity,
- unit_price: item.unit_price,
- estimate_price: item.unit_price,
- warehouse_price: null,
- competitor_price: null,
- description: item.description || '',
- category: item.category || getCategoryFromLotName(item.lot_name)
- }));
- }
-
- // Update price update date
- if (config.price_updated_at) {
- updatePriceUpdateDate(config.price_updated_at);
- }
- if (config.pricelist_id) {
- if (selectedPricelistIds.estimate) {
- selectedPricelistIds.estimate = config.pricelist_id;
- } else {
- resolvedAutoPricelistIds.estimate = Number(config.pricelist_id);
- }
- if (!activePricelistsBySource.estimate.some(opt => Number(opt.id) === Number(config.pricelist_id))) {
- await loadActivePricelists();
- }
- syncPriceSettingsControls();
- renderPricelistSettingsSummary();
- if (selectedPricelistIds.estimate) {
- persistLocalPriceSettings();
- }
- }
-
- // Re-render UI
+ await saveConfig(false);
await refreshPriceLevels({ force: true, noCache: true });
renderTab();
updateCartUI();
+ if (configUUID) {
+ const configResp = await fetch('/api/configs/' + configUUID);
+ if (configResp.ok) {
+ const config = await configResp.json();
+ if (config.price_updated_at) {
+ updatePriceUpdateDate(config.price_updated_at);
+ }
+ }
+ }
+
showToast('Цены обновлены', 'success');
} catch(e) {
showToast('Ошибка обновления цен', 'error');
+ } finally {
+ if (refreshBtn) {
+ refreshBtn.disabled = false;
+ refreshBtn.textContent = previousLabel || 'Обновить цены';
+ updateRefreshPricesButtonState();
+ }
}
}
@@ -4019,14 +4091,17 @@ function applyCustomPrice(table) {
function onBuyCustomPriceInput() {
applyCustomPrice('buy');
+ triggerAutoSave();
}
function onSaleCustomPriceInput() {
applyCustomPrice('sale');
+ triggerAutoSave();
}
function onSaleMarkupInput() {
renderPricingTab();
+ triggerAutoSave();
}
function setPricingCustomPriceFromVendor() {