feat: унифицировать autocomplete для LOT на всех вкладках
- Все вкладки (storage, pci, power, accessories, sw, other) теперь используют редактируемый autocomplete-input для существующих позиций, как на вкладке base; выбор заменяет позицию с сохранением количества - LOT-поле в BOM-таблицах переведено на общий autocomplete dropdown вместо datalist - Кнопка ✕ в BOM снимает сопоставление вместо удаления строки - Кнопка «Пересчитать эстимейт» переименована в «Перенести в эстимейт» Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -194,7 +194,7 @@
|
||||
Сохранить BOM
|
||||
</button>
|
||||
<button onclick="applyBOMToEstimate()" class="px-3 py-1 bg-orange-600 text-white rounded hover:bg-orange-700">
|
||||
Пересчитать эстимейт
|
||||
Перенести в эстимейт
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -542,7 +542,8 @@ let componentPricesCacheLoading = new Map(); // { category: Promise } - tracks o
|
||||
// Autocomplete state
|
||||
let autocompleteInput = null;
|
||||
let autocompleteCategory = null;
|
||||
let autocompleteMode = null; // 'single', 'multi', 'section'
|
||||
let autocompleteMode = null; // 'single', 'multi', 'section', 'edit-item'
|
||||
let autocompleteEditCategories = null;
|
||||
let autocompleteIndex = -1;
|
||||
let autocompleteFiltered = [];
|
||||
|
||||
@@ -1389,7 +1390,16 @@ function renderMultiSelectTab(components) {
|
||||
|
||||
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 min-w-48">
|
||||
<div class="autocomplete-wrapper relative">
|
||||
<input type="text"
|
||||
value="${escapeHtml(item.lot_name)}"
|
||||
class="w-full px-2 py-1 border rounded text-sm font-mono"
|
||||
onfocus="showAutocompleteEditItem('${item.lot_name}', this)"
|
||||
oninput="filterAutocompleteEditItem(this.value)"
|
||||
onkeydown="handleAutocompleteKeyEditItem(event)">
|
||||
</div>
|
||||
</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">${formatPriceOrNA(item.estimate_price ?? item.unit_price)}</td>
|
||||
<td class="px-3 py-2 text-center">
|
||||
@@ -1483,6 +1493,10 @@ function renderMultiSelectTabWithSections(sections) {
|
||||
<tbody class="divide-y">
|
||||
`;
|
||||
|
||||
// Add empty row for new item in this section
|
||||
const sectionId = section.categories.join('-');
|
||||
const categoriesStr = section.categories.join(',');
|
||||
|
||||
// Render existing cart items for this section
|
||||
sectionItems.forEach((item) => {
|
||||
const comp = allComponents.find(c => c.lot_name === item.lot_name);
|
||||
@@ -1490,7 +1504,17 @@ function renderMultiSelectTabWithSections(sections) {
|
||||
|
||||
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 min-w-48">
|
||||
<div class="autocomplete-wrapper relative">
|
||||
<input type="text"
|
||||
value="${escapeHtml(item.lot_name)}"
|
||||
data-categories="${categoriesStr}"
|
||||
class="w-full px-2 py-1 border rounded text-sm font-mono"
|
||||
onfocus="showAutocompleteEditItem('${item.lot_name}', this)"
|
||||
oninput="filterAutocompleteEditItem(this.value)"
|
||||
onkeydown="handleAutocompleteKeyEditItem(event)">
|
||||
</div>
|
||||
</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">${formatPriceOrNA(item.estimate_price ?? item.unit_price)}</td>
|
||||
<td class="px-3 py-2 text-center">
|
||||
@@ -1511,8 +1535,6 @@ function renderMultiSelectTabWithSections(sections) {
|
||||
});
|
||||
|
||||
// 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">
|
||||
@@ -1640,6 +1662,10 @@ function renderAutocomplete() {
|
||||
onmousedown = `selectAutocompleteItemSection(${idx}, '${autocompleteCategory}')`;
|
||||
} else if (autocompleteMode === 'multi') {
|
||||
onmousedown = `selectAutocompleteItemMulti(${idx})`;
|
||||
} else if (autocompleteMode === 'bom') {
|
||||
onmousedown = `selectAutocompleteItemBOM(${idx}, ${autocompleteCategory})`;
|
||||
} else if (autocompleteMode === 'edit-item') {
|
||||
onmousedown = `selectAutocompleteEditItem(${idx})`;
|
||||
} else {
|
||||
// single mode
|
||||
onmousedown = `selectAutocompleteItem(${idx})`;
|
||||
@@ -1921,6 +1947,138 @@ function selectAutocompleteItemSection(index, sectionId) {
|
||||
schedulePriceLevelsRefresh({ delay: 80, rerender: true, autosave: false });
|
||||
}
|
||||
|
||||
// Autocomplete for editing an existing cart item's LOT (multi/section tabs)
|
||||
async function showAutocompleteEditItem(lotName, input) {
|
||||
autocompleteInput = input;
|
||||
autocompleteCategory = lotName;
|
||||
autocompleteMode = 'edit-item';
|
||||
autocompleteIndex = -1;
|
||||
autocompleteEditCategories = input.dataset.categories
|
||||
? input.dataset.categories.split(',').map(c => c.trim().toUpperCase())
|
||||
: null;
|
||||
const components = getComponentsForTab(currentTab);
|
||||
await ensurePricesLoaded(components);
|
||||
filterAutocompleteEditItem(input.value);
|
||||
}
|
||||
|
||||
function filterAutocompleteEditItem(search) {
|
||||
const searchLower = (search || '').toLowerCase();
|
||||
const components = autocompleteEditCategories
|
||||
? allComponents.filter(c => autocompleteEditCategories.includes(getComponentCategory(c)))
|
||||
: getComponentsForTab(currentTab);
|
||||
autocompleteFiltered = components.filter(c => {
|
||||
if (!hasComponentPrice(c.lot_name)) return false;
|
||||
if (!isComponentAllowedByStockFilter(c)) return false;
|
||||
return (c.lot_name + ' ' + (c.description || '')).toLowerCase().includes(searchLower);
|
||||
}).sort((a, b) => {
|
||||
const d = (b.popularity_score || 0) - (a.popularity_score || 0);
|
||||
return d !== 0 ? d : a.lot_name.localeCompare(b.lot_name);
|
||||
});
|
||||
renderAutocomplete();
|
||||
}
|
||||
|
||||
function handleAutocompleteKeyEditItem(event) {
|
||||
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) {
|
||||
selectAutocompleteEditItem(autocompleteIndex);
|
||||
}
|
||||
} else if (event.key === 'Escape') {
|
||||
hideAutocomplete();
|
||||
}
|
||||
}
|
||||
|
||||
function selectAutocompleteEditItem(index) {
|
||||
const comp = autocompleteFiltered[index];
|
||||
if (!comp) return;
|
||||
const lotName = autocompleteCategory;
|
||||
const oldItem = cart.find(i => i.lot_name === lotName);
|
||||
const qty = oldItem?.quantity || 1;
|
||||
cart = cart.filter(i => i.lot_name !== lotName);
|
||||
const price = componentPricesCache[comp.lot_name] || 0;
|
||||
cart.push({
|
||||
lot_name: comp.lot_name,
|
||||
quantity: qty,
|
||||
unit_price: price,
|
||||
estimate_price: price,
|
||||
warehouse_price: null,
|
||||
competitor_price: null,
|
||||
delta_wh_estimate_abs: null,
|
||||
delta_wh_estimate_pct: null,
|
||||
delta_comp_estimate_abs: null,
|
||||
delta_comp_estimate_pct: null,
|
||||
delta_comp_wh_abs: null,
|
||||
delta_comp_wh_pct: null,
|
||||
price_missing: ['warehouse', 'competitor'],
|
||||
description: comp.description || '',
|
||||
category: getComponentCategory(comp)
|
||||
});
|
||||
hideAutocomplete();
|
||||
renderTab();
|
||||
updateCartUI();
|
||||
triggerAutoSave();
|
||||
schedulePriceLevelsRefresh({ delay: 80, rerender: true, autosave: false });
|
||||
}
|
||||
|
||||
// Autocomplete for BOM LOT mapping
|
||||
function showAutocompleteBOM(rowIdx, input) {
|
||||
autocompleteInput = input;
|
||||
autocompleteCategory = rowIdx;
|
||||
autocompleteMode = 'bom';
|
||||
autocompleteIndex = -1;
|
||||
filterAutocompleteBOM(rowIdx, input.value);
|
||||
}
|
||||
|
||||
function filterAutocompleteBOM(rowIdx, search) {
|
||||
const searchLower = (search || '').toLowerCase();
|
||||
autocompleteFiltered = (window._bomAllComponents || allComponents).filter(c => {
|
||||
const text = (c.lot_name + ' ' + (c.description || '')).toLowerCase();
|
||||
return text.includes(searchLower);
|
||||
}).sort((a, b) => {
|
||||
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 handleAutocompleteKeyBOM(event, rowIdx) {
|
||||
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) {
|
||||
selectAutocompleteItemBOM(autocompleteIndex, rowIdx);
|
||||
}
|
||||
} else if (event.key === 'Escape') {
|
||||
hideAutocomplete();
|
||||
}
|
||||
}
|
||||
|
||||
function selectAutocompleteItemBOM(index, rowIdx) {
|
||||
const comp = autocompleteFiltered[index];
|
||||
if (!comp) return;
|
||||
const row = bomRows.find(r => r.source_row_index === rowIdx) || bomRows[rowIdx];
|
||||
if (!row) return;
|
||||
row.manual_lot = comp.lot_name;
|
||||
hideAutocomplete();
|
||||
resolveBOM();
|
||||
}
|
||||
|
||||
function clearSingleSelect(category) {
|
||||
cart = cart.filter(item =>
|
||||
(item.category || getCategoryFromLotName(item.lot_name)).toUpperCase() !== category.toUpperCase()
|
||||
@@ -2968,6 +3126,18 @@ function deleteBOMRawRow(rowIdx) {
|
||||
rebuildBOMRowsFromRaw();
|
||||
}
|
||||
|
||||
function clearBOMLotMapping(rowIdx) {
|
||||
const row = bomRows.find(r => r.source_row_index === rowIdx);
|
||||
if (!row) return;
|
||||
row.manual_lot = '';
|
||||
row.resolved_lot = '';
|
||||
row.resolution_source = 'unresolved';
|
||||
row.lot_allocations = [];
|
||||
row.bundle_enabled = false;
|
||||
renderBOMTable();
|
||||
debouncedResolveBOM();
|
||||
}
|
||||
|
||||
function _bomRawLotCell(rowIdx) {
|
||||
if (!bomImportRaw || bomImportRaw.mode !== 'raw') return '—';
|
||||
if (bomImportRaw.ignoredRows?.[rowIdx]) return '<span class="text-gray-400">—</span>';
|
||||
@@ -2989,13 +3159,12 @@ function _bomRawLotCell(rowIdx) {
|
||||
|
||||
if (isUnresolved) {
|
||||
const val = map.manual_lot || '';
|
||||
const invalid = val && !_bomLotValid(val);
|
||||
return `<input type="text" placeholder="LOT..." value="${escapeHtml(val)}"
|
||||
class="w-full min-w-28 px-2 py-1 border rounded text-xs ${invalid ? 'border-red-400 bg-red-50' : ''}"
|
||||
list="lot-autocomplete-list"
|
||||
oninput="setBOMManualLotDraft(${rowIdx}, this.value, this)"
|
||||
onchange="commitBOMManualLot(${rowIdx}, this)"
|
||||
onblur="commitBOMManualLot(${rowIdx}, this)">
|
||||
return `<div class="autocomplete-wrapper relative"><input type="text" placeholder="Введите артикул..."
|
||||
value="${escapeHtml(val)}"
|
||||
class="w-full min-w-28 px-2 py-1 border rounded text-xs font-mono"
|
||||
onfocus="showAutocompleteBOM(${rowIdx}, this)"
|
||||
oninput="filterAutocompleteBOM(${rowIdx}, this.value); setBOMManualLotDraft(${rowIdx}, this.value, this)"
|
||||
onkeydown="handleAutocompleteKeyBOM(event, ${rowIdx})"></div>
|
||||
${renderBOMLotAllocationsEditor(rowIdx)}`;
|
||||
}
|
||||
let suffix = '';
|
||||
@@ -3379,12 +3548,12 @@ function _renderBOMParsedTable() {
|
||||
|
||||
let lotCell = '';
|
||||
if (isUnresolved) {
|
||||
lotCell = `<input type="text" placeholder="Введите LOT..." value="${escapeHtml(row.manual_lot || '')}"
|
||||
class="w-full px-2 py-1 border rounded text-sm focus:ring-1 focus:ring-blue-400"
|
||||
oninput="bomRows[${idx}].manual_lot = this.value; this.classList.toggle('border-red-400', this.value && !_bomLotValid(this.value));"
|
||||
onchange="if(_bomLotValid(this.value)){bomRows[${idx}].manual_lot=this.value;resolveBOM(); this.classList.remove('border-red-400');}else{this.value=bomRows[${idx}].manual_lot||'';}"
|
||||
onblur="if(this.value && !_bomLotValid(this.value)){this.value=bomRows[${idx}].manual_lot||'';}"
|
||||
list="lot-autocomplete-list">${renderBOMLotAllocationsEditor(idx)}`;
|
||||
lotCell = `<div class="autocomplete-wrapper relative"><input type="text" placeholder="Введите артикул..."
|
||||
value="${escapeHtml(row.manual_lot || '')}"
|
||||
class="w-full px-2 py-1 border rounded text-sm font-mono focus:ring-1 focus:ring-blue-400"
|
||||
onfocus="showAutocompleteBOM(${row.source_row_index}, this)"
|
||||
oninput="filterAutocompleteBOM(${row.source_row_index}, this.value); bomRows.find(r=>r.source_row_index===${row.source_row_index}).manual_lot=this.value;"
|
||||
onkeydown="handleAutocompleteKeyBOM(event, ${row.source_row_index})"></div>${renderBOMLotAllocationsEditor(idx)}`;
|
||||
} else {
|
||||
let suffix = '';
|
||||
if (qtyMismatch) suffix = ` <span class="text-yellow-600 text-xs">≠est(${cartQty})</span>`;
|
||||
@@ -3474,7 +3643,7 @@ function _renderBOMRawTable() {
|
||||
<td class="w-12 px-1 py-1 border-b text-center align-top whitespace-nowrap">
|
||||
<button type="button" title="Добавить LOT в bundle" onclick="addBOMAllocation(${rowIdx})" class="inline-block text-xs px-1 text-gray-400 hover:text-blue-600">+</button>
|
||||
<button type="button" title="${ignored ? 'Не игнорировать' : 'Игнорировать'}" onclick="toggleBOMRawRowIgnored(${rowIdx})" class="inline-block text-xs px-1 ${ignored ? 'text-blue-600' : 'text-gray-400 hover:text-gray-700'}">${ignored ? '◉' : '○'}</button>
|
||||
<button type="button" title="Удалить строку" onclick="deleteBOMRawRow(${rowIdx})" class="inline-block text-xs px-1 text-gray-400 hover:text-red-600">✕</button>
|
||||
<button type="button" title="Снять сопоставление" onclick="clearBOMLotMapping(${rowIdx})" class="inline-block text-xs px-1 text-gray-400 hover:text-red-600">✕</button>
|
||||
</td>`;
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user