Добавлены сортировка по категориям, секции PCI и автосохранение
Основные изменения: 1. CSV экспорт и веб-интерфейс: - Компоненты теперь сортируются по иерархии категорий (display_order) - Категории отображаются в правильном порядке: BB, CPU, MEM, GPU и т.д. - Компоненты без категории отображаются в конце 2. Раздел PCI в конфигураторе: - Разделен на секции: GPU/DPU, NIC/HCA, HBA - Улучшена навигация и выбор компонентов 3. Сохранение "своей цены": - Добавлено поле custom_price в модель Configuration - Создана миграция 002_add_custom_price.sql - "Своя цена" сохраняется при сохранении конфигурации - При загрузке конфигурации восстанавливается сохраненная цена 4. Автосохранение: - Конфигурация автоматически сохраняется через 1 секунду после изменений - Debounce предотвращает избыточные запросы - Автосохранение работает для всех изменений (компоненты, количество, цена) 5. Дополнительно: - Добавлен cmd/importer для импорта метаданных из таблицы lot - Создан скрипт apply_migration.sh для применения миграций - Оптимизирована работа с категориями в ExportService Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -81,7 +81,7 @@
|
||||
<input type="number" id="custom-price-input" step="0.01" min="0"
|
||||
placeholder="0.00"
|
||||
class="flex-1 px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500"
|
||||
oninput="calculateCustomPrice()">
|
||||
oninput="calculateCustomPrice(); triggerAutoSave();">
|
||||
<button onclick="clearCustomPrice()" class="px-3 py-2 text-gray-500 hover:text-gray-700">
|
||||
<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="M6 18L18 6M6 6l12 12"></path>
|
||||
@@ -149,22 +149,31 @@
|
||||
</style>
|
||||
|
||||
<script>
|
||||
// Tab configuration
|
||||
const TAB_CONFIG = {
|
||||
// Tab configuration - will be populated dynamically
|
||||
let TAB_CONFIG = {
|
||||
base: {
|
||||
categories: ['MB', 'CPU', 'MEM'],
|
||||
singleSelect: true,
|
||||
label: 'Base'
|
||||
},
|
||||
storage: {
|
||||
categories: ['M2', 'SSD', 'HDD', 'EDSFF', 'HHHL'],
|
||||
categories: ['RAID', 'M2', 'SSD', 'HDD', 'EDSFF', 'HHHL'],
|
||||
singleSelect: false,
|
||||
label: 'Storage'
|
||||
label: 'Storage',
|
||||
sections: [
|
||||
{ title: 'RAID Контроллеры', categories: ['RAID'] },
|
||||
{ title: 'Диски', categories: ['M2', 'SSD', 'HDD', 'EDSFF', 'HHHL'] }
|
||||
]
|
||||
},
|
||||
pci: {
|
||||
categories: ['HBA', 'HCA', 'NIC', 'GPU', 'RAID', 'DPU'],
|
||||
categories: ['GPU', 'DPU', 'NIC', 'HCA', 'HBA'],
|
||||
singleSelect: false,
|
||||
label: 'PCI'
|
||||
label: 'PCI',
|
||||
sections: [
|
||||
{ title: 'GPU / DPU', categories: ['GPU', 'DPU'] },
|
||||
{ title: 'NIC / HCA', categories: ['NIC', 'HCA'] },
|
||||
{ title: 'HBA', categories: ['HBA'] }
|
||||
]
|
||||
},
|
||||
power: {
|
||||
categories: ['PS', 'PSU'],
|
||||
@@ -183,7 +192,7 @@ const TAB_CONFIG = {
|
||||
}
|
||||
};
|
||||
|
||||
const ASSIGNED_CATEGORIES = Object.values(TAB_CONFIG)
|
||||
let ASSIGNED_CATEGORIES = Object.values(TAB_CONFIG)
|
||||
.flatMap(t => t.categories)
|
||||
.map(c => c.toUpperCase());
|
||||
|
||||
@@ -193,13 +202,51 @@ let configName = '';
|
||||
let currentTab = 'base';
|
||||
let allComponents = [];
|
||||
let cart = [];
|
||||
let categoryOrderMap = {}; // Category code -> display_order mapping
|
||||
let autoSaveTimeout = null; // Timeout for debounced autosave
|
||||
|
||||
// Autocomplete state
|
||||
let autocompleteInput = null;
|
||||
let autocompleteCategory = null;
|
||||
let autocompleteMode = null; // 'single', 'multi', 'section'
|
||||
let autocompleteIndex = -1;
|
||||
let autocompleteFiltered = [];
|
||||
|
||||
// Load categories from API and update tab configuration
|
||||
async function loadCategoriesFromAPI() {
|
||||
try {
|
||||
const resp = await fetch('/api/categories');
|
||||
const cats = await resp.json();
|
||||
|
||||
// Build category order map
|
||||
categoryOrderMap = {};
|
||||
cats.forEach(cat => {
|
||||
categoryOrderMap[cat.code.toUpperCase()] = cat.display_order;
|
||||
});
|
||||
|
||||
// Build list of unassigned categories
|
||||
const knownCodes = Object.values(TAB_CONFIG)
|
||||
.flatMap(t => t.categories)
|
||||
.map(c => c.toUpperCase());
|
||||
|
||||
const unassignedCategories = cats
|
||||
.filter(cat => !knownCodes.includes(cat.code.toUpperCase()))
|
||||
.sort((a, b) => a.display_order - b.display_order)
|
||||
.map(cat => cat.code);
|
||||
|
||||
// Update "other" tab with unassigned categories
|
||||
TAB_CONFIG.other.categories = unassignedCategories;
|
||||
|
||||
// Rebuild ASSIGNED_CATEGORIES
|
||||
ASSIGNED_CATEGORIES = Object.values(TAB_CONFIG)
|
||||
.flatMap(t => t.categories)
|
||||
.map(c => c.toUpperCase());
|
||||
} catch(e) {
|
||||
console.error('Failed to load categories, using defaults', e);
|
||||
// Will use default configuration if API fails
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize
|
||||
document.addEventListener('DOMContentLoaded', async function() {
|
||||
const token = localStorage.getItem('token');
|
||||
@@ -209,6 +256,9 @@ document.addEventListener('DOMContentLoaded', async function() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Load categories first
|
||||
await loadCategoriesFromAPI();
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/configs/' + configUUID, {
|
||||
headers: {'Authorization': 'Bearer ' + token}
|
||||
@@ -239,6 +289,11 @@ document.addEventListener('DOMContentLoaded', async function() {
|
||||
category: item.category || getCategoryFromLotName(item.lot_name)
|
||||
}));
|
||||
}
|
||||
|
||||
// Restore custom price if saved
|
||||
if (config.custom_price) {
|
||||
document.getElementById('custom-price-input').value = config.custom_price.toFixed(2);
|
||||
}
|
||||
} catch(e) {
|
||||
showToast('Ошибка загрузки конфигурации', 'error');
|
||||
window.location.href = '/configs';
|
||||
@@ -325,6 +380,8 @@ function renderTab() {
|
||||
|
||||
if (config.singleSelect) {
|
||||
renderSingleSelectTab(config.categories);
|
||||
} else if (config.sections) {
|
||||
renderMultiSelectTabWithSections(config.sections);
|
||||
} else {
|
||||
renderMultiSelectTab(components);
|
||||
}
|
||||
@@ -479,10 +536,120 @@ function renderMultiSelectTab(components) {
|
||||
document.getElementById('tab-content').innerHTML = html;
|
||||
}
|
||||
|
||||
function renderMultiSelectTabWithSections(sections) {
|
||||
// Get cart items for this tab
|
||||
const tabItems = cart.filter(item => {
|
||||
const cat = (item.category || getCategoryFromLotName(item.lot_name)).toUpperCase();
|
||||
const tab = getTabForCategory(cat);
|
||||
return tab === currentTab;
|
||||
});
|
||||
|
||||
let html = '';
|
||||
let totalComponents = 0;
|
||||
|
||||
sections.forEach((section, sectionIdx) => {
|
||||
// Get components for this section's categories
|
||||
const sectionCategories = section.categories.map(c => c.toUpperCase());
|
||||
const sectionComponents = allComponents.filter(comp => {
|
||||
const category = getComponentCategory(comp);
|
||||
return sectionCategories.includes(category);
|
||||
});
|
||||
totalComponents += sectionComponents.length;
|
||||
|
||||
// Get cart items for this section
|
||||
const sectionItems = tabItems.filter(item => {
|
||||
const cat = (item.category || getCategoryFromLotName(item.lot_name)).toUpperCase();
|
||||
return sectionCategories.includes(cat);
|
||||
});
|
||||
|
||||
// Section header
|
||||
html += `
|
||||
<div class="mb-6 ${sectionIdx > 0 ? 'mt-6' : ''}">
|
||||
<h3 class="text-sm font-semibold text-gray-700 mb-3 px-3">${section.title}</h3>
|
||||
<table class="w-full">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">LOT</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Описание</th>
|
||||
<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase w-24">Цена</th>
|
||||
<th class="px-3 py-2 text-center text-xs font-medium text-gray-500 uppercase w-20">Кол-во</th>
|
||||
<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase w-28">Стоимость</th>
|
||||
<th class="px-3 py-2 w-10"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y">
|
||||
`;
|
||||
|
||||
// Render existing cart items for this section
|
||||
sectionItems.forEach((item) => {
|
||||
const comp = allComponents.find(c => c.lot_name === item.lot_name);
|
||||
const total = item.unit_price * item.quantity;
|
||||
|
||||
html += `
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="px-3 py-2 text-sm font-mono">${escapeHtml(item.lot_name)}</td>
|
||||
<td class="px-3 py-2 text-sm text-gray-500 truncate max-w-xs">${escapeHtml(item.description || comp?.description || '')}</td>
|
||||
<td class="px-3 py-2 text-sm text-right">$${item.unit_price.toFixed(2)}</td>
|
||||
<td class="px-3 py-2 text-center">
|
||||
<input type="number" min="1" value="${item.quantity}"
|
||||
onchange="updateMultiQuantity('${item.lot_name}', this.value)"
|
||||
class="w-16 px-2 py-1 border rounded text-center text-sm">
|
||||
</td>
|
||||
<td class="px-3 py-2 text-sm text-right font-medium">$${total.toFixed(2)}</td>
|
||||
<td class="px-3 py-2 text-center">
|
||||
<button onclick="removeFromCart('${item.lot_name}')" class="text-red-500 hover:text-red-700">
|
||||
<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="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
});
|
||||
|
||||
// Add empty row for new item in this section
|
||||
const sectionId = section.categories.join('-');
|
||||
const categoriesStr = section.categories.join(',');
|
||||
html += `
|
||||
<tr class="hover:bg-gray-50 bg-gray-50">
|
||||
<td class="px-3 py-2" colspan="2">
|
||||
<div class="autocomplete-wrapper relative">
|
||||
<input type="text"
|
||||
id="input-section-${sectionId}"
|
||||
data-categories="${categoriesStr}"
|
||||
placeholder="Добавить ${section.title.toLowerCase()}..."
|
||||
class="w-full px-2 py-1 border rounded text-sm"
|
||||
onfocus="showAutocompleteSection('${sectionId}', this)"
|
||||
oninput="filterAutocompleteSection('${sectionId}', this.value, this)"
|
||||
onkeydown="handleAutocompleteKeySection(event, '${sectionId}')">
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-3 py-2 text-sm text-right text-gray-400" id="new-price-${sectionId}">—</td>
|
||||
<td class="px-3 py-2 text-center">
|
||||
<input type="number" min="1" value="1" id="new-qty-${sectionId}"
|
||||
class="w-16 px-2 py-1 border rounded text-center text-sm">
|
||||
</td>
|
||||
<td class="px-3 py-2 text-sm text-right text-gray-400" id="new-total-${sectionId}">—</td>
|
||||
<td class="px-3 py-2"></td>
|
||||
</tr>
|
||||
`;
|
||||
|
||||
html += `
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="text-center text-sm text-gray-500 mt-2">Доступно: ${sectionComponents.length}</p>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
document.getElementById('tab-content').innerHTML = html;
|
||||
}
|
||||
|
||||
// Autocomplete for single select (Base tab)
|
||||
function showAutocomplete(category, input) {
|
||||
autocompleteInput = input;
|
||||
autocompleteCategory = category;
|
||||
autocompleteMode = 'single';
|
||||
autocompleteIndex = -1;
|
||||
filterAutocomplete(category, input.value);
|
||||
}
|
||||
@@ -519,16 +686,27 @@ function renderAutocomplete() {
|
||||
dropdown.style.left = rect.left + 'px';
|
||||
dropdown.style.width = Math.max(rect.width, 400) + 'px';
|
||||
|
||||
// Use different select function based on mode (single vs multi)
|
||||
const selectFn = autocompleteCategory ? 'selectAutocompleteItem' : 'selectAutocompleteItemMulti';
|
||||
// Build autocomplete items based on mode
|
||||
dropdown.innerHTML = autocompleteFiltered.map((comp, idx) => {
|
||||
let onmousedown;
|
||||
|
||||
dropdown.innerHTML = autocompleteFiltered.map((comp, idx) => `
|
||||
<div class="autocomplete-item ${idx === autocompleteIndex ? 'selected' : ''}"
|
||||
onmousedown="${selectFn}(${idx})">
|
||||
<div class="font-mono text-sm">${escapeHtml(comp.lot_name)}</div>
|
||||
<div class="text-xs text-gray-500 truncate">${escapeHtml(comp.description || '')}</div>
|
||||
</div>
|
||||
`).join('');
|
||||
if (autocompleteMode === 'section') {
|
||||
onmousedown = `selectAutocompleteItemSection(${idx}, '${autocompleteCategory}')`;
|
||||
} else if (autocompleteMode === 'multi') {
|
||||
onmousedown = `selectAutocompleteItemMulti(${idx})`;
|
||||
} else {
|
||||
// single mode
|
||||
onmousedown = `selectAutocompleteItem(${idx})`;
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="autocomplete-item ${idx === autocompleteIndex ? 'selected' : ''}"
|
||||
onmousedown="${onmousedown}">
|
||||
<div class="font-mono text-sm">${escapeHtml(comp.lot_name)}</div>
|
||||
<div class="text-xs text-gray-500 truncate">${escapeHtml(comp.description || '')}</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
dropdown.classList.remove('hidden');
|
||||
}
|
||||
@@ -575,12 +753,14 @@ function selectAutocompleteItem(index) {
|
||||
hideAutocomplete();
|
||||
renderTab();
|
||||
updateCartUI();
|
||||
triggerAutoSave();
|
||||
}
|
||||
|
||||
function hideAutocomplete() {
|
||||
document.getElementById('autocomplete-dropdown').classList.add('hidden');
|
||||
autocompleteInput = null;
|
||||
autocompleteCategory = null;
|
||||
autocompleteMode = null;
|
||||
autocompleteIndex = -1;
|
||||
}
|
||||
|
||||
@@ -588,6 +768,7 @@ function hideAutocomplete() {
|
||||
function showAutocompleteMulti(input) {
|
||||
autocompleteInput = input;
|
||||
autocompleteCategory = null;
|
||||
autocompleteMode = 'multi';
|
||||
autocompleteIndex = -1;
|
||||
filterAutocompleteMulti(input.value);
|
||||
}
|
||||
@@ -652,6 +833,102 @@ function selectAutocompleteItemMulti(index) {
|
||||
hideAutocomplete();
|
||||
renderTab();
|
||||
updateCartUI();
|
||||
triggerAutoSave();
|
||||
}
|
||||
|
||||
// Autocomplete for sectioned tabs (like storage with RAID and Disks sections)
|
||||
function showAutocompleteSection(sectionId, input) {
|
||||
autocompleteInput = input;
|
||||
autocompleteCategory = sectionId; // Store section ID
|
||||
autocompleteMode = 'section';
|
||||
autocompleteIndex = -1;
|
||||
filterAutocompleteSection(sectionId, input.value, input);
|
||||
}
|
||||
|
||||
function filterAutocompleteSection(sectionId, search, inputElement) {
|
||||
const searchLower = search.toLowerCase();
|
||||
|
||||
// Get categories from input element's data attribute
|
||||
const categoriesStr = inputElement && inputElement.dataset ? inputElement.dataset.categories : '';
|
||||
if (!categoriesStr) {
|
||||
autocompleteFiltered = [];
|
||||
renderAutocomplete();
|
||||
return;
|
||||
}
|
||||
|
||||
const categoryList = categoriesStr.split(',').map(c => c.trim().toUpperCase());
|
||||
|
||||
// Get components for this section's categories
|
||||
const sectionComponents = allComponents.filter(comp => {
|
||||
const category = getComponentCategory(comp);
|
||||
return categoryList.includes(category);
|
||||
});
|
||||
|
||||
// Filter out already added items
|
||||
const addedLots = new Set(cart.map(i => i.lot_name));
|
||||
|
||||
autocompleteFiltered = sectionComponents.filter(c => {
|
||||
if (!c.current_price) return false;
|
||||
if (addedLots.has(c.lot_name)) return false;
|
||||
const text = (c.lot_name + ' ' + (c.description || '')).toLowerCase();
|
||||
return text.includes(searchLower);
|
||||
})
|
||||
.sort((a, b) => {
|
||||
// Sort by popularity_score desc, then by lot_name
|
||||
const popDiff = (b.popularity_score || 0) - (a.popularity_score || 0);
|
||||
if (popDiff !== 0) return popDiff;
|
||||
return a.lot_name.localeCompare(b.lot_name);
|
||||
});
|
||||
|
||||
renderAutocomplete();
|
||||
}
|
||||
|
||||
function handleAutocompleteKeySection(event, sectionId) {
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
autocompleteIndex = Math.min(autocompleteIndex + 1, autocompleteFiltered.length - 1);
|
||||
renderAutocomplete();
|
||||
} else if (event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
autocompleteIndex = Math.max(autocompleteIndex - 1, -1);
|
||||
renderAutocomplete();
|
||||
} else if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
if (autocompleteIndex >= 0 && autocompleteIndex < autocompleteFiltered.length) {
|
||||
selectAutocompleteItemSection(autocompleteIndex, sectionId);
|
||||
}
|
||||
} else if (event.key === 'Escape') {
|
||||
hideAutocomplete();
|
||||
}
|
||||
}
|
||||
|
||||
function selectAutocompleteItemSection(index, sectionId) {
|
||||
const comp = autocompleteFiltered[index];
|
||||
if (!comp) return;
|
||||
|
||||
const qtyInput = document.getElementById('new-qty-' + sectionId);
|
||||
const qty = parseInt(qtyInput?.value) || 1;
|
||||
|
||||
cart.push({
|
||||
lot_name: comp.lot_name,
|
||||
quantity: qty,
|
||||
unit_price: comp.current_price,
|
||||
description: comp.description || '',
|
||||
category: getComponentCategory(comp)
|
||||
});
|
||||
|
||||
hideAutocomplete();
|
||||
|
||||
// Clear the input field
|
||||
const input = document.getElementById('input-section-' + sectionId);
|
||||
if (input) input.value = '';
|
||||
|
||||
// Reset quantity to 1
|
||||
if (qtyInput) qtyInput.value = '1';
|
||||
|
||||
renderTab();
|
||||
updateCartUI();
|
||||
triggerAutoSave();
|
||||
}
|
||||
|
||||
function clearSingleSelect(category) {
|
||||
@@ -660,6 +937,7 @@ function clearSingleSelect(category) {
|
||||
);
|
||||
renderTab();
|
||||
updateCartUI();
|
||||
triggerAutoSave();
|
||||
}
|
||||
|
||||
function updateSingleQuantity(category, value) {
|
||||
@@ -672,6 +950,7 @@ function updateSingleQuantity(category, value) {
|
||||
item.quantity = Math.max(1, qty);
|
||||
renderTab();
|
||||
updateCartUI();
|
||||
triggerAutoSave();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -682,6 +961,7 @@ function updateMultiQuantity(lotName, value) {
|
||||
if (item) {
|
||||
item.quantity = Math.max(1, qty);
|
||||
updateCartUI();
|
||||
triggerAutoSave();
|
||||
// Update total in the row
|
||||
const row = document.querySelector(`input[onchange*="${lotName}"]`)?.closest('tr');
|
||||
if (row) {
|
||||
@@ -697,6 +977,7 @@ function removeFromCart(lotName) {
|
||||
cart = cart.filter(i => i.lot_name !== lotName);
|
||||
renderTab();
|
||||
updateCartUI();
|
||||
triggerAutoSave();
|
||||
}
|
||||
|
||||
function updateCartUI() {
|
||||
@@ -712,16 +993,38 @@ function updateCartUI() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Sort cart items by category display order
|
||||
const sortedCart = [...cart].sort((a, b) => {
|
||||
const catA = (a.category || getCategoryFromLotName(a.lot_name)).toUpperCase();
|
||||
const catB = (b.category || getCategoryFromLotName(b.lot_name)).toUpperCase();
|
||||
const orderA = categoryOrderMap[catA] || 9999;
|
||||
const orderB = categoryOrderMap[catB] || 9999;
|
||||
return orderA - orderB;
|
||||
});
|
||||
|
||||
const grouped = {};
|
||||
cart.forEach(item => {
|
||||
sortedCart.forEach(item => {
|
||||
const cat = item.category || getCategoryFromLotName(item.lot_name);
|
||||
const tab = getTabForCategory(cat);
|
||||
if (!grouped[tab]) grouped[tab] = [];
|
||||
grouped[tab].push(item);
|
||||
});
|
||||
|
||||
// Sort tabs by minimum display order of their categories
|
||||
const sortedTabs = Object.entries(grouped).sort((a, b) => {
|
||||
const minOrderA = Math.min(...a[1].map(item => {
|
||||
const cat = (item.category || getCategoryFromLotName(item.lot_name)).toUpperCase();
|
||||
return categoryOrderMap[cat] || 9999;
|
||||
}));
|
||||
const minOrderB = Math.min(...b[1].map(item => {
|
||||
const cat = (item.category || getCategoryFromLotName(item.lot_name)).toUpperCase();
|
||||
return categoryOrderMap[cat] || 9999;
|
||||
}));
|
||||
return minOrderA - minOrderB;
|
||||
});
|
||||
|
||||
let html = '';
|
||||
for (const [tab, items] of Object.entries(grouped)) {
|
||||
for (const [tab, items] of sortedTabs) {
|
||||
const tabLabel = TAB_CONFIG[tab]?.label || tab;
|
||||
html += `<div class="mb-2"><div class="text-xs font-medium text-gray-400 uppercase mb-1">${tabLabel}</div>`;
|
||||
|
||||
@@ -759,10 +1062,25 @@ function escapeHtml(text) {
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
async function saveConfig() {
|
||||
function triggerAutoSave() {
|
||||
// Debounce autosave - wait 1 second after last change
|
||||
if (autoSaveTimeout) {
|
||||
clearTimeout(autoSaveTimeout);
|
||||
}
|
||||
autoSaveTimeout = setTimeout(() => {
|
||||
saveConfig(false); // false = don't show notification
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
async function saveConfig(showNotification = true) {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token || !configUUID) return;
|
||||
|
||||
// Get custom price if set
|
||||
const customPriceInput = document.getElementById('custom-price-input');
|
||||
const customPriceValue = parseFloat(customPriceInput.value);
|
||||
const customPrice = customPriceValue > 0 ? customPriceValue : null;
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/configs/' + configUUID, {
|
||||
method: 'PUT',
|
||||
@@ -773,6 +1091,7 @@ async function saveConfig() {
|
||||
body: JSON.stringify({
|
||||
name: configName,
|
||||
items: cart,
|
||||
custom_price: customPrice,
|
||||
notes: ''
|
||||
})
|
||||
});
|
||||
@@ -783,13 +1102,19 @@ async function saveConfig() {
|
||||
}
|
||||
|
||||
if (!resp.ok) {
|
||||
showToast('Ошибка сохранения', 'error');
|
||||
if (showNotification) {
|
||||
showToast('Ошибка сохранения', 'error');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
showToast('Сохранено', 'success');
|
||||
if (showNotification) {
|
||||
showToast('Сохранено', 'success');
|
||||
}
|
||||
} catch(e) {
|
||||
showToast('Ошибка сохранения', 'error');
|
||||
if (showNotification) {
|
||||
showToast('Ошибка сохранения', 'error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -847,11 +1172,20 @@ function calculateCustomPrice() {
|
||||
}
|
||||
|
||||
// Build adjusted prices table
|
||||
// Sort cart items by category display order
|
||||
const sortedCart = [...cart].sort((a, b) => {
|
||||
const catA = (a.category || getCategoryFromLotName(a.lot_name)).toUpperCase();
|
||||
const catB = (b.category || getCategoryFromLotName(b.lot_name)).toUpperCase();
|
||||
const orderA = categoryOrderMap[catA] || 9999;
|
||||
const orderB = categoryOrderMap[catB] || 9999;
|
||||
return orderA - orderB;
|
||||
});
|
||||
|
||||
let html = '';
|
||||
let totalOriginal = 0;
|
||||
let totalNew = 0;
|
||||
|
||||
cart.forEach(item => {
|
||||
sortedCart.forEach(item => {
|
||||
const originalPrice = item.unit_price;
|
||||
const newPrice = originalPrice * coefficient;
|
||||
const itemOriginalTotal = originalPrice * item.quantity;
|
||||
@@ -882,6 +1216,7 @@ function clearCustomPrice() {
|
||||
document.getElementById('custom-price-input').value = '';
|
||||
document.getElementById('adjusted-prices').classList.add('hidden');
|
||||
document.getElementById('discount-info').classList.add('hidden');
|
||||
triggerAutoSave();
|
||||
}
|
||||
|
||||
async function exportCSVWithCustomPrice() {
|
||||
|
||||
Reference in New Issue
Block a user