Добавлены сортировка по категориям, секции 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:
Mikhail Chusavitin
2026-01-30 17:48:44 +03:00
parent db37040399
commit d32b1c5d0c
16 changed files with 971 additions and 168 deletions

View File

@@ -64,6 +64,31 @@
</div>
</div>
<!-- Modal for cloning configuration -->
<div id="clone-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
<div class="bg-white rounded-lg shadow-xl w-full max-w-md mx-4 p-6">
<h2 class="text-xl font-semibold mb-4">Копировать конфигурацию</h2>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Название копии</label>
<input type="text" id="clone-input" placeholder="Введите название"
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<input type="hidden" id="clone-uuid">
</div>
</div>
<div class="flex justify-end space-x-3 mt-6">
<button onclick="closeCloneModal()" class="px-4 py-2 text-gray-600 hover:text-gray-800">
Отмена
</button>
<button onclick="cloneConfig()" class="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700">
Копировать
</button>
</div>
</div>
</div>
<script>
async function loadConfigs() {
const token = localStorage.getItem('token');
@@ -121,6 +146,7 @@ function renderConfigs(configs) {
html += '<td class="px-4 py-3 text-sm text-gray-500">' + date + '</td>';
html += '<td class="px-4 py-3 text-sm text-right">' + total + '</td>';
html += '<td class="px-4 py-3 text-sm text-right space-x-2">';
html += '<button onclick="openCloneModal(\'' + c.uuid + '\', \'' + escapeHtml(c.name).replace(/'/g, "\\'") + '\')" class="text-green-600 hover:text-green-800">Копировать</button>';
html += '<button onclick="openRenameModal(\'' + c.uuid + '\', \'' + escapeHtml(c.name).replace(/'/g, "\\'") + '\')" class="text-blue-600 hover:text-blue-800">Переименовать</button>';
html += '<button onclick="deleteConfig(\'' + c.uuid + '\')" class="text-red-600 hover:text-red-800">Удалить</button>';
html += '</td></tr>';
@@ -203,6 +229,63 @@ async function renameConfig() {
}
}
function openCloneModal(uuid, currentName) {
const token = localStorage.getItem('token');
if (!token) {
window.location.href = '/login';
return;
}
document.getElementById('clone-uuid').value = uuid;
document.getElementById('clone-input').value = currentName + ' (копия)';
document.getElementById('clone-modal').classList.remove('hidden');
document.getElementById('clone-modal').classList.add('flex');
document.getElementById('clone-input').focus();
document.getElementById('clone-input').select();
}
function closeCloneModal() {
document.getElementById('clone-modal').classList.add('hidden');
document.getElementById('clone-modal').classList.remove('flex');
}
async function cloneConfig() {
const token = localStorage.getItem('token');
const uuid = document.getElementById('clone-uuid').value;
const name = document.getElementById('clone-input').value.trim();
if (!name) {
alert('Введите название');
return;
}
try {
const resp = await fetch('/api/configs/' + uuid + '/clone', {
method: 'POST',
headers: {
'Authorization': 'Bearer ' + token,
'Content-Type': 'application/json'
},
body: JSON.stringify({ name: name })
});
if (resp.status === 401) {
logout();
return;
}
if (!resp.ok) {
const err = await resp.json();
alert('Ошибка: ' + (err.error || 'Не удалось скопировать'));
return;
}
closeCloneModal();
loadConfigs();
} catch(e) {
alert('Ошибка копирования');
}
}
function openCreateModal() {
const token = localStorage.getItem('token');
if (!token) {
@@ -274,11 +357,18 @@ document.getElementById('rename-modal').addEventListener('click', function(e) {
}
});
document.getElementById('clone-modal').addEventListener('click', function(e) {
if (e.target === this) {
closeCloneModal();
}
});
// Close modal on Escape key
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
closeCreateModal();
closeRenameModal();
closeCloneModal();
}
});
@@ -289,6 +379,13 @@ document.getElementById('rename-input').addEventListener('keydown', function(e)
}
});
// Submit clone on Enter key
document.getElementById('clone-input').addEventListener('keydown', function(e) {
if (e.key === 'Enter') {
cloneConfig();
}
});
document.addEventListener('DOMContentLoaded', loadConfigs);
</script>
{{end}}

View File

@@ -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() {