Implement warehouse/lot pricing updates and configurator performance fixes
This commit is contained in:
@@ -7,7 +7,8 @@
|
||||
<div class="bg-white rounded-lg shadow p-4">
|
||||
<div class="flex justify-between items-center border-b pb-4 mb-4">
|
||||
<div class="flex gap-4">
|
||||
<button onclick="loadTab('alerts')" id="btn-alerts" class="text-blue-600 font-medium">Алерты</button>
|
||||
<button onclick="loadTab('lots')" id="btn-lots" class="text-blue-600 font-medium">LOT</button>
|
||||
<button onclick="loadTab('pricelists')" id="btn-pricelists" class="text-gray-600">Прайслисты</button>
|
||||
<button onclick="loadTab('estimate')" id="btn-estimate" class="text-gray-600">Estimate</button>
|
||||
<button onclick="loadTab('warehouse')" id="btn-warehouse" class="text-gray-600">Склад</button>
|
||||
<button onclick="loadTab('competitor')" id="btn-competitor" class="text-gray-600">Конкуренты</button>
|
||||
@@ -33,21 +34,22 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search and sort (only for components) -->
|
||||
<!-- Search and sort (for LOT/component-settings) -->
|
||||
<div id="search-bar" class="mb-4 hidden">
|
||||
<div class="flex gap-4 items-center">
|
||||
<input type="text" id="search-input" placeholder="Поиск по артикулу..."
|
||||
<input type="text" id="search-input" placeholder="Поиск..."
|
||||
class="flex-1 px-3 py-2 border rounded"
|
||||
onkeyup="debounceSearch()">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm text-gray-500">Сортировка:</span>
|
||||
<select id="sort-field" class="px-2 py-1 border rounded text-sm" onchange="changeSort()">
|
||||
<option value="lot_name">Артикул</option>
|
||||
<option value="popularity_score" selected>Популярность</option>
|
||||
<option value="quote_count">Кол-во котировок</option>
|
||||
<option value="current_price">Цена</option>
|
||||
<option value="category">Категория</option>
|
||||
<option value="popularity_score">Популярность</option>
|
||||
<option value="estimate_count">Котировок</option>
|
||||
<option value="stock_qty">На складе</option>
|
||||
</select>
|
||||
<button onclick="toggleSortDir()" id="sort-dir-btn" class="px-2 py-1 border rounded text-sm">↓</button>
|
||||
<button onclick="toggleSortDir()" id="sort-dir-btn" class="px-2 py-1 border rounded text-sm">↑</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -75,6 +77,7 @@
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Версия</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Тип прайслиста</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Дата</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Автор</th>
|
||||
<th class="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase">Позиций</th>
|
||||
@@ -85,7 +88,7 @@
|
||||
</thead>
|
||||
<tbody id="pricelists-body" class="bg-white divide-y divide-gray-200">
|
||||
<tr>
|
||||
<td colspan="7" class="px-6 py-4 text-center text-gray-500">Загрузка...</td>
|
||||
<td colspan="8" class="px-6 py-4 text-center text-gray-500">Загрузка...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -96,6 +99,7 @@
|
||||
<div id="stock-tools" class="mt-6 hidden space-y-6">
|
||||
<div class="border rounded-lg p-4">
|
||||
<h3 class="text-lg font-semibold mb-3">Импорт stock_log</h3>
|
||||
<p class="text-xs text-gray-500 mb-3">В stock_log сохраняются все неотфильтрованные строки. Поле <span class="font-mono">partnumber</span> берется из файла; сопоставление с LOT используется только при расчете warehouse-прайслиста.</p>
|
||||
<div class="flex items-center gap-3">
|
||||
<input type="file" id="stock-file-input" accept=".mxl,.xlsx" class="block w-full text-sm text-gray-700">
|
||||
<button onclick="importStockFile()" class="px-4 py-2 bg-emerald-600 text-white rounded hover:bg-emerald-700">Импортировать</button>
|
||||
@@ -148,7 +152,7 @@
|
||||
<div class="border rounded-lg p-4">
|
||||
<h3 class="text-lg font-semibold mb-3">Сопоставление partnumber -> LOT</h3>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<input id="stock-mappings-search" type="text" placeholder="Поиск по partnumber / описанию / lot" class="px-3 py-2 border rounded w-full">
|
||||
<input id="stock-mappings-search" type="text" placeholder="Поиск по partnumber / описанию / LOT" class="px-3 py-2 border rounded w-full">
|
||||
<datalist id="stock-lot-options"></datalist>
|
||||
<button onclick="loadStockMappings(1)" class="px-3 py-2 border rounded hover:bg-gray-50">Обновить</button>
|
||||
</div>
|
||||
@@ -358,7 +362,7 @@
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let currentTab = 'alerts';
|
||||
let currentTab = 'lots';
|
||||
let currentPricelistSource = 'estimate';
|
||||
let currentPage = 1;
|
||||
let totalPages = 1;
|
||||
@@ -366,8 +370,8 @@ let perPage = 50;
|
||||
let searchTimeout = null;
|
||||
let currentSearch = '';
|
||||
let componentsCache = [];
|
||||
let sortField = 'popularity_score';
|
||||
let sortDir = 'desc';
|
||||
let sortField = 'lot_name';
|
||||
let sortDir = 'asc';
|
||||
let pricelistsPage = 1;
|
||||
let pricelistsCanWrite = false;
|
||||
let isCreatingPricelist = false;
|
||||
@@ -379,6 +383,82 @@ let stockMappingsSearch = '';
|
||||
let stockMappingsSearchTimer = null;
|
||||
let stockImportSuggestions = [];
|
||||
|
||||
function getPricelistTabConfig(tab) {
|
||||
if (tab === 'estimate') {
|
||||
return { source: 'estimate', title: 'Estimate', showEstimateSettings: true, showStockTools: false };
|
||||
}
|
||||
if (tab === 'warehouse') {
|
||||
return { source: 'warehouse', title: 'Склад', showEstimateSettings: false, showStockTools: true };
|
||||
}
|
||||
if (tab === 'competitor') {
|
||||
return { source: 'competitor', title: 'Конкуренты', showEstimateSettings: false, showStockTools: false };
|
||||
}
|
||||
return { source: '', title: 'Прайслисты', showEstimateSettings: false, showStockTools: false };
|
||||
}
|
||||
|
||||
function formatPricelistSourceLabel(source) {
|
||||
if (source === 'warehouse') return 'Склад';
|
||||
if (source === 'competitor') return 'Конкуренты';
|
||||
return 'Estimate';
|
||||
}
|
||||
|
||||
function canCreatePricelistForCurrentTab() {
|
||||
return currentPricelistSource === 'estimate' || currentPricelistSource === 'warehouse' || currentPricelistSource === 'competitor';
|
||||
}
|
||||
|
||||
function updatePricelistsCreateButton() {
|
||||
if (pricelistsCanWrite && canCreatePricelistForCurrentTab()) {
|
||||
document.getElementById('pricelists-create-btn-container').innerHTML = `
|
||||
<button onclick="openPricelistsCreateModal()" class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700">
|
||||
Создать прайслист
|
||||
</button>
|
||||
`;
|
||||
} else {
|
||||
document.getElementById('pricelists-create-btn-container').innerHTML = '';
|
||||
}
|
||||
}
|
||||
|
||||
const SORT_OPTIONS = {
|
||||
lots: [
|
||||
{ value: 'lot_name', label: 'Артикул' },
|
||||
{ value: 'category', label: 'Категория' },
|
||||
{ value: 'popularity_score', label: 'Популярность' },
|
||||
{ value: 'estimate_count', label: 'Котировок' },
|
||||
{ value: 'stock_qty', label: 'На складе' }
|
||||
],
|
||||
'component-settings': [
|
||||
{ value: 'lot_name', label: 'Артикул' },
|
||||
{ value: 'popularity_score', label: 'Популярность' },
|
||||
{ value: 'quote_count', label: 'Кол-во котировок' },
|
||||
{ value: 'current_price', label: 'Цена' }
|
||||
]
|
||||
};
|
||||
|
||||
function setSortConfigForTab(tab) {
|
||||
const searchInput = document.getElementById('search-input');
|
||||
const sortSelect = document.getElementById('sort-field');
|
||||
const sortDirBtn = document.getElementById('sort-dir-btn');
|
||||
|
||||
let options = SORT_OPTIONS.lots;
|
||||
let defaultSort = 'lot_name';
|
||||
let defaultDir = 'asc';
|
||||
let placeholder = 'Поиск по LOT или описанию...';
|
||||
|
||||
if (tab === 'component-settings') {
|
||||
options = SORT_OPTIONS['component-settings'];
|
||||
defaultSort = 'popularity_score';
|
||||
defaultDir = 'desc';
|
||||
placeholder = 'Поиск по артикулу...';
|
||||
}
|
||||
|
||||
sortSelect.innerHTML = options.map(o => `<option value="${o.value}">${o.label}</option>`).join('');
|
||||
sortField = options.some(o => o.value === sortField) ? sortField : defaultSort;
|
||||
sortDir = defaultDir;
|
||||
sortSelect.value = sortField;
|
||||
sortDirBtn.textContent = sortDir === 'asc' ? '↑' : '↓';
|
||||
searchInput.placeholder = placeholder;
|
||||
}
|
||||
|
||||
async function loadTab(tab) {
|
||||
currentTab = tab;
|
||||
currentPage = 1;
|
||||
@@ -388,32 +468,44 @@ async function loadTab(tab) {
|
||||
stopSyncUsersStatusRefresh();
|
||||
}
|
||||
|
||||
document.getElementById('btn-alerts').className = tab === 'alerts' ? 'text-blue-600 font-medium' : 'text-gray-600';
|
||||
document.getElementById('btn-lots').className = tab === 'lots' ? 'text-blue-600 font-medium' : 'text-gray-600';
|
||||
document.getElementById('btn-pricelists').className = tab === 'pricelists' ? 'text-blue-600 font-medium' : 'text-gray-600';
|
||||
document.getElementById('btn-estimate').className = (tab === 'estimate' || tab === 'component-settings') ? 'text-blue-600 font-medium' : 'text-gray-600';
|
||||
document.getElementById('btn-warehouse').className = tab === 'warehouse' ? 'text-blue-600 font-medium' : 'text-gray-600';
|
||||
document.getElementById('btn-competitor').className = tab === 'competitor' ? 'text-blue-600 font-medium' : 'text-gray-600';
|
||||
document.getElementById('btn-sync-status').className = (tab === 'sync-status' ? 'text-blue-600 font-medium' : 'text-gray-600') + (pricelistsCanWrite ? '' : ' hidden');
|
||||
document.getElementById('btn-all-configs').className = tab === 'all-configs' ? 'text-blue-600 font-medium' : 'text-gray-600 hidden';
|
||||
|
||||
if (tab === 'estimate' || tab === 'warehouse' || tab === 'competitor') {
|
||||
currentPricelistSource = tab === 'estimate' ? 'estimate' : (tab === 'warehouse' ? 'warehouse' : 'competitor');
|
||||
if (tab === 'lots') {
|
||||
setSortConfigForTab('lots');
|
||||
document.getElementById('search-bar').className = 'mb-4';
|
||||
document.getElementById('pagination').className = 'flex justify-between items-center mt-4 pt-4 border-t';
|
||||
document.getElementById('btn-all-configs').className = 'text-gray-600 hidden';
|
||||
document.getElementById('pricelists-tab-content').className = 'hidden';
|
||||
document.getElementById('sync-status-tab-content').className = 'hidden';
|
||||
document.getElementById('tab-content').className = '';
|
||||
await loadData();
|
||||
} else if (tab === 'pricelists' || tab === 'estimate' || tab === 'warehouse' || tab === 'competitor') {
|
||||
const pricelistTab = getPricelistTabConfig(tab);
|
||||
currentPricelistSource = pricelistTab.source;
|
||||
document.getElementById('search-bar').className = 'mb-4 hidden';
|
||||
document.getElementById('pagination').className = 'hidden';
|
||||
document.getElementById('btn-all-configs').className = 'text-gray-600 hidden';
|
||||
document.getElementById('pricelists-tab-content').className = '';
|
||||
document.getElementById('sync-status-tab-content').className = 'hidden';
|
||||
document.getElementById('tab-content').className = 'hidden';
|
||||
document.getElementById('pricelists-title').textContent = tab === 'estimate' ? 'Estimate' : (tab === 'warehouse' ? 'Склад' : 'Конкуренты');
|
||||
document.getElementById('estimate-settings-btn').classList.toggle('hidden', tab !== 'estimate');
|
||||
document.getElementById('stock-tools').classList.toggle('hidden', tab !== 'warehouse');
|
||||
document.getElementById('pricelists-title').textContent = pricelistTab.title;
|
||||
document.getElementById('estimate-settings-btn').classList.toggle('hidden', !pricelistTab.showEstimateSettings);
|
||||
document.getElementById('stock-tools').classList.toggle('hidden', !pricelistTab.showStockTools);
|
||||
await checkPricelistWritePermission();
|
||||
await loadPricelists(1, currentPricelistSource);
|
||||
if (tab === 'warehouse') {
|
||||
if (pricelistTab.showStockTools) {
|
||||
await loadStockMappings(1);
|
||||
await loadStockIgnoreRules(1);
|
||||
await loadStockLotOptions();
|
||||
}
|
||||
} else if (tab === 'component-settings') {
|
||||
setSortConfigForTab('component-settings');
|
||||
document.getElementById('search-bar').className = 'mb-4';
|
||||
document.getElementById('pagination').className = 'flex justify-between items-center mt-4 pt-4 border-t';
|
||||
document.getElementById('btn-all-configs').className = 'text-gray-600 hidden';
|
||||
@@ -430,7 +522,7 @@ async function loadTab(tab) {
|
||||
document.getElementById('tab-content').className = 'hidden';
|
||||
await checkPricelistWritePermission();
|
||||
if (!pricelistsCanWrite) {
|
||||
await loadTab('alerts');
|
||||
await loadTab('lots');
|
||||
return;
|
||||
}
|
||||
await loadUsersSyncStatus();
|
||||
@@ -474,10 +566,22 @@ async function loadData() {
|
||||
document.getElementById('tab-content').innerHTML = '<div class="text-center py-8 text-gray-500">Загрузка...</div>';
|
||||
|
||||
try {
|
||||
if (currentTab === 'alerts') {
|
||||
const resp = await fetch('/api/admin/pricing/alerts?per_page=100');
|
||||
if (currentTab === 'lots') {
|
||||
let url = '/api/admin/pricing/lots-table?page=' + currentPage + '&per_page=' + perPage;
|
||||
if (currentSearch) {
|
||||
url += '&search=' + encodeURIComponent(currentSearch);
|
||||
}
|
||||
if (sortField) {
|
||||
url += '&sort=' + encodeURIComponent(sortField);
|
||||
}
|
||||
if (sortDir) {
|
||||
url += '&dir=' + encodeURIComponent(sortDir);
|
||||
}
|
||||
const resp = await fetch(url);
|
||||
const data = await resp.json();
|
||||
renderAlerts(data.alerts || []);
|
||||
totalPages = Math.max(1, Math.ceil((data.total || 0) / perPage));
|
||||
renderLots(data.lots || [], data.total || 0);
|
||||
updatePagination(data.total || 0);
|
||||
} else if (currentTab === 'all-configs') {
|
||||
// Load all configurations for all users
|
||||
let url = '/api/configs?page=' + currentPage + '&per_page=' + perPage;
|
||||
@@ -544,21 +648,52 @@ function updatePagination(total) {
|
||||
document.getElementById('btn-next').disabled = currentPage >= totalPages;
|
||||
}
|
||||
|
||||
function renderAlerts(alerts) {
|
||||
if (alerts.length === 0) {
|
||||
document.getElementById('tab-content').innerHTML = '<div class="text-center py-8 text-green-600">Нет активных алертов</div>';
|
||||
function renderLots(lots, total) {
|
||||
if (lots.length === 0) {
|
||||
document.getElementById('tab-content').innerHTML = '<div class="text-center py-8 text-gray-500">Нет данных</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '<div class="space-y-2">';
|
||||
alerts.forEach(a => {
|
||||
const colors = {critical: 'bg-red-100', high: 'bg-orange-100', medium: 'bg-yellow-100', low: 'bg-blue-100'};
|
||||
html += '<div class="' + (colors[a.severity] || 'bg-gray-100') + ' p-3 rounded">';
|
||||
html += '<div class="flex justify-between"><span class="font-medium">' + escapeHtml(a.lot_name) + '</span>';
|
||||
html += '<span class="text-xs uppercase">' + a.severity + '</span></div>';
|
||||
html += '<p class="text-sm text-gray-600">' + escapeHtml(a.message) + '</p></div>';
|
||||
let html = '<div class="text-sm text-gray-500 mb-2">Всего LOT: ' + total + '</div>';
|
||||
html += '<div class="overflow-x-auto"><table class="w-full"><thead class="bg-gray-50"><tr>';
|
||||
html += '<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Категория</th>';
|
||||
html += '<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">LOT</th>';
|
||||
html += '<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">p/n</th>';
|
||||
html += '<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Описание</th>';
|
||||
html += '<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase">Популярность</th>';
|
||||
html += '<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase">Котировок</th>';
|
||||
html += '<th class="px-3 py-2 text-center text-xs font-medium text-gray-500 uppercase">Конкуренты</th>';
|
||||
html += '<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase">На складе</th>';
|
||||
html += '</tr></thead><tbody class="divide-y">';
|
||||
|
||||
lots.forEach(lot => {
|
||||
const category = lot.category ? escapeHtml(lot.category) : '—';
|
||||
const lotName = lot.lot_name ? escapeHtml(lot.lot_name) : '—';
|
||||
const description = lot.lot_description ? escapeHtml(lot.lot_description) : '—';
|
||||
const popularity = Number.isFinite(lot.popularity) ? Number(lot.popularity).toFixed(2) : '0.00';
|
||||
const estimateCount = Number.isFinite(lot.estimate_count) ? lot.estimate_count.toLocaleString('ru-RU') : '0';
|
||||
const stockQty = lot.stock_qty === null || lot.stock_qty === undefined
|
||||
? '—'
|
||||
: Number(lot.stock_qty).toLocaleString('ru-RU', { maximumFractionDigits: 2 });
|
||||
const partnumbers = Array.isArray(lot.partnumbers) ? lot.partnumbers : [];
|
||||
const firstPart = partnumbers.length > 0 ? escapeHtml(partnumbers[0]) : '—';
|
||||
const more = partnumbers.length > 1
|
||||
? `<span class="ml-2 inline-flex items-center px-2 py-0.5 rounded-full bg-gray-100 text-gray-600 text-xs">+${partnumbers.length - 1}</span>`
|
||||
: '';
|
||||
|
||||
html += '<tr class="hover:bg-gray-50">';
|
||||
html += '<td class="px-3 py-2 text-sm">' + category + '</td>';
|
||||
html += '<td class="px-3 py-2 text-sm font-medium">' + lotName + '</td>';
|
||||
html += '<td class="px-3 py-2 text-sm">' + firstPart + more + '</td>';
|
||||
html += '<td class="px-3 py-2 text-sm text-gray-600">' + description + '</td>';
|
||||
html += '<td class="px-3 py-2 text-sm text-right">' + popularity + '</td>';
|
||||
html += '<td class="px-3 py-2 text-sm text-right">' + estimateCount + '</td>';
|
||||
html += '<td class="px-3 py-2 text-sm text-center">—</td>';
|
||||
html += '<td class="px-3 py-2 text-sm text-right">' + stockQty + '</td>';
|
||||
html += '</tr>';
|
||||
});
|
||||
html += '</div>';
|
||||
|
||||
html += '</tbody></table></div>';
|
||||
document.getElementById('tab-content').innerHTML = html;
|
||||
}
|
||||
|
||||
@@ -1124,6 +1259,30 @@ async function importStockFile() {
|
||||
percentEl.textContent = '0%';
|
||||
barEl.style.width = '0%';
|
||||
statsEl.textContent = '';
|
||||
const statsState = {
|
||||
rowsTotal: 0,
|
||||
validRows: 0,
|
||||
inserted: 0,
|
||||
unmapped: 0,
|
||||
conflicts: 0,
|
||||
ignored: 0,
|
||||
parseErrors: 0,
|
||||
qtyParseErrors: 0,
|
||||
};
|
||||
|
||||
const setIfNumber = (obj, key, value) => {
|
||||
if (value === null || value === undefined || value === '') return;
|
||||
const n = Number(value);
|
||||
if (!Number.isNaN(n)) obj[key] = n;
|
||||
};
|
||||
|
||||
const renderStats = () => {
|
||||
statsEl.textContent =
|
||||
`Всего строк: ${statsState.rowsTotal} | Валидных: ${statsState.validRows} | ` +
|
||||
`Вставлено: ${statsState.inserted} | Без сопоставления: ${statsState.unmapped} | ` +
|
||||
`Конфликты: ${statsState.conflicts} | Игнор: ${statsState.ignored} | ` +
|
||||
`Ошибки парсинга: ${statsState.parseErrors} | Ошибки количества: ${statsState.qtyParseErrors}`;
|
||||
};
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/admin/pricing/stock/import', {
|
||||
@@ -1138,36 +1297,53 @@ async function importStockFile() {
|
||||
|
||||
const reader = resp.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let sseBuffer = '';
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
const text = decoder.decode(value);
|
||||
const lines = text.split('\n');
|
||||
sseBuffer += decoder.decode(value, { stream: true });
|
||||
const lines = sseBuffer.split('\n');
|
||||
sseBuffer = lines.pop() || '';
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith('data:')) continue;
|
||||
let data;
|
||||
try { data = JSON.parse(line.slice(5).trim()); } catch (_) { continue; }
|
||||
setIfNumber(statsState, 'rowsTotal', data.rows_total);
|
||||
setIfNumber(statsState, 'validRows', data.valid_rows);
|
||||
setIfNumber(statsState, 'inserted', data.inserted);
|
||||
setIfNumber(statsState, 'unmapped', data.unmapped);
|
||||
setIfNumber(statsState, 'conflicts', data.conflicts);
|
||||
setIfNumber(statsState, 'ignored', data.ignored);
|
||||
setIfNumber(statsState, 'parseErrors', data.parse_errors);
|
||||
setIfNumber(statsState, 'qtyParseErrors', data.qty_parse_errors);
|
||||
renderStats();
|
||||
|
||||
const current = Number(data.current || 0);
|
||||
const total = Number(data.total || 100);
|
||||
const pct = total > 0 ? Math.round((current / total) * 100) : 0;
|
||||
let pct = total > 0 ? Math.round((current / total) * 100) : 0;
|
||||
if (data.status !== 'completed' && pct >= 100) {
|
||||
pct = 99;
|
||||
}
|
||||
barEl.style.width = pct + '%';
|
||||
percentEl.textContent = pct + '%';
|
||||
statusEl.textContent = data.message || data.status || 'Обработка';
|
||||
statsEl.textContent =
|
||||
`Валидных: ${data.valid_rows || 0} | Вставлено: ${data.inserted || 0} | ` +
|
||||
`Пропущено: ${(data.unmapped || 0) + (data.conflicts || 0) + (data.parse_errors || 0) + (data.ignored || 0)} | ` +
|
||||
`Игнор: ${data.ignored || 0} | Конфликты: ${data.conflicts || 0}`;
|
||||
|
||||
if (data.status === 'error') {
|
||||
throw new Error(data.message || 'Ошибка импорта');
|
||||
}
|
||||
if (data.status === 'completed') {
|
||||
barEl.style.width = '100%';
|
||||
percentEl.textContent = '100%';
|
||||
statusEl.textContent = 'Импорт завершен. Обновляю списки...';
|
||||
stockImportSuggestions = data.mapping_suggestions || [];
|
||||
renderStockImportSuggestions(stockImportSuggestions);
|
||||
showToast('Импорт stock_log завершен', 'success');
|
||||
await loadPricelists(1, 'warehouse');
|
||||
await loadStockMappings(stockMappingsPage);
|
||||
await loadStockIgnoreRules(stockIgnoreRulesPage);
|
||||
await Promise.allSettled([
|
||||
loadPricelists(1, 'warehouse'),
|
||||
loadStockMappings(stockMappingsPage),
|
||||
loadStockIgnoreRules(stockIgnoreRulesPage),
|
||||
]);
|
||||
statusEl.textContent = 'Импорт и обновление списков завершены';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1178,7 +1354,7 @@ async function importStockFile() {
|
||||
|
||||
function formatSuggestionReason(reason) {
|
||||
if (reason === 'conflict') return 'Конфликт';
|
||||
return 'Не найден LOT';
|
||||
return 'Нет сопоставления с LOT';
|
||||
}
|
||||
|
||||
function renderStockImportSuggestions(items) {
|
||||
@@ -1563,8 +1739,8 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
await checkPricelistWritePermission();
|
||||
// Check URL params for initial tab
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
let initialTab = urlParams.get('tab') || 'alerts';
|
||||
if (initialTab === 'pricelists') initialTab = 'estimate';
|
||||
let initialTab = urlParams.get('tab') || 'lots';
|
||||
if (initialTab === 'alerts') initialTab = 'lots';
|
||||
if (initialTab === 'components') initialTab = 'component-settings';
|
||||
await loadTab(initialTab);
|
||||
const stockMappingsSearchEl = document.getElementById('stock-mappings-search');
|
||||
@@ -1579,9 +1755,6 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
document.getElementById('modal-meta-prices').addEventListener('input', debounceFetchPreview);
|
||||
});
|
||||
|
||||
// Pricelists functions
|
||||
let canWrite = false;
|
||||
|
||||
async function checkPricelistWritePermission() {
|
||||
try {
|
||||
const resp = await fetch('/api/pricelists/can-write');
|
||||
@@ -1589,26 +1762,24 @@ async function checkPricelistWritePermission() {
|
||||
pricelistsCanWrite = data.can_write;
|
||||
|
||||
if (pricelistsCanWrite) {
|
||||
document.getElementById('pricelists-create-btn-container').innerHTML = `
|
||||
<button onclick="openPricelistsCreateModal()" class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700">
|
||||
Создать прайслист
|
||||
</button>
|
||||
`;
|
||||
updatePricelistsCreateButton();
|
||||
document.getElementById('btn-sync-status').classList.remove('hidden');
|
||||
if (currentTab === 'sync-status') {
|
||||
await loadUsersSyncStatus();
|
||||
startSyncUsersStatusRefresh();
|
||||
}
|
||||
} else {
|
||||
document.getElementById('pricelists-create-btn-container').innerHTML = '';
|
||||
updatePricelistsCreateButton();
|
||||
document.getElementById('btn-sync-status').classList.add('hidden');
|
||||
stopSyncUsersStatusRefresh();
|
||||
if (currentTab === 'sync-status') {
|
||||
await loadTab('alerts');
|
||||
await loadTab('lots');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to check pricelist write permission:', e);
|
||||
pricelistsCanWrite = false;
|
||||
updatePricelistsCreateButton();
|
||||
document.getElementById('btn-sync-status').classList.add('hidden');
|
||||
stopSyncUsersStatusRefresh();
|
||||
}
|
||||
@@ -1685,13 +1856,16 @@ async function loadPricelists(page = 1, source = currentPricelistSource) {
|
||||
try {
|
||||
const resp = await fetch(`/api/pricelists?page=${page}&per_page=20&source=${encodeURIComponent(source)}`);
|
||||
const data = await resp.json();
|
||||
if (!resp.ok) {
|
||||
throw new Error(data.error || 'Ошибка загрузки прайслистов');
|
||||
}
|
||||
|
||||
renderPricelists(data.pricelists || []);
|
||||
renderPricelistsPagination(data.total, data.page, data.per_page);
|
||||
} catch (e) {
|
||||
document.getElementById('pricelists-body').innerHTML = `
|
||||
<tr>
|
||||
<td colspan="7" class="px-6 py-4 text-center text-red-500">
|
||||
<td colspan="8" class="px-6 py-4 text-center text-red-500">
|
||||
Ошибка загрузки: ${e.message}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -1705,7 +1879,7 @@ function renderPricelists(pricelists) {
|
||||
if (pricelists.length === 0) {
|
||||
document.getElementById('pricelists-body').innerHTML = `
|
||||
<tr>
|
||||
<td colspan="7" class="px-6 py-4 text-center text-gray-500">
|
||||
<td colspan="8" class="px-6 py-4 text-center text-gray-500">
|
||||
Прайслисты не найдены. ${pricelistsCanWrite ? 'Создайте первый прайслист.' : ''}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -1717,21 +1891,39 @@ function renderPricelists(pricelists) {
|
||||
const date = new Date(pl.created_at).toLocaleDateString('ru-RU');
|
||||
const statusClass = pl.is_active ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800';
|
||||
const statusText = pl.is_active ? 'Активен' : 'Неактивен';
|
||||
const sourceLabel = formatPricelistSourceLabel(pl.source);
|
||||
|
||||
let actions = `<a href="/pricelists/${pl.id}" class="text-blue-600 hover:text-blue-800 text-sm">Просмотр</a>`;
|
||||
let actions = '';
|
||||
if (pricelistsCanWrite) {
|
||||
const toggleLabel = pl.is_active ? 'Деактивировать' : 'Активировать';
|
||||
actions += ` <button onclick="togglePricelistActive(${pl.id}, ${pl.is_active ? 'false' : 'true'})" class="text-indigo-600 hover:text-indigo-800 text-sm ml-2">${toggleLabel}</button>`;
|
||||
const toggleTitle = pl.is_active ? 'Деактивировать' : 'Активировать';
|
||||
actions += ` <button onclick="togglePricelistActive(${pl.id}, ${pl.is_active ? 'false' : 'true'})" class="text-indigo-600 hover:text-indigo-800" title="${toggleTitle}">
|
||||
<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="M5 12h14"></path>
|
||||
</svg>
|
||||
</button>`;
|
||||
}
|
||||
if (pricelistsCanWrite && pl.usage_count === 0) {
|
||||
actions += ` <button onclick="deletePricelist(${pl.id})" class="text-red-600 hover:text-red-800 text-sm ml-2">Удалить</button>`;
|
||||
actions += ` <button onclick="deletePricelist(${pl.id})" class="text-red-600 hover:text-red-800" title="Удалить">
|
||||
<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 7h12M9 7V5a1 1 0 011-1h4a1 1 0 011 1v2m-7 0v12m4-12v12m4-12v12"></path>
|
||||
</svg>
|
||||
</button>`;
|
||||
} else {
|
||||
actions += ` <span class="text-gray-300" title="Удаление недоступно">
|
||||
<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 7h12M9 7V5a1 1 0 011-1h4a1 1 0 011 1v2m-7 0v12m4-12v12m4-12v12"></path>
|
||||
</svg>
|
||||
</span>`;
|
||||
}
|
||||
|
||||
const versionCell = `<a href="/pricelists/${pl.id}" class="font-mono text-sm text-blue-600 hover:text-blue-800 hover:underline">${pl.version}</a>`;
|
||||
|
||||
return `
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span class="font-mono text-sm">${pl.version}</span>
|
||||
${versionCell}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">${sourceLabel}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">${date}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">${pl.created_by || '-'}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-center text-sm">${pl.item_count}</td>
|
||||
@@ -1739,7 +1931,9 @@ function renderPricelists(pricelists) {
|
||||
<td class="px-6 py-4 whitespace-nowrap text-center">
|
||||
<span class="px-2 py-1 text-xs rounded-full ${statusClass}">${statusText}</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right">${actions}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right">
|
||||
<div class="inline-flex items-center justify-end gap-3">${actions}</div>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
@@ -1807,6 +2001,10 @@ async function loadPricelistsDbUsername() {
|
||||
}
|
||||
|
||||
function openPricelistsCreateModal() {
|
||||
if (!canCreatePricelistForCurrentTab()) {
|
||||
showToast('Создание доступно только на вкладках Estimate, Склад и Конкуренты', 'error');
|
||||
return;
|
||||
}
|
||||
document.getElementById('pricelists-create-modal').classList.remove('hidden');
|
||||
document.getElementById('pricelists-create-modal').classList.add('flex');
|
||||
resetPricelistCreateProgress();
|
||||
@@ -1829,6 +2027,9 @@ async function checkOnlineStatus() {
|
||||
}
|
||||
|
||||
async function createPricelist() {
|
||||
if (!canCreatePricelistForCurrentTab()) {
|
||||
throw new Error('Выберите вкладку Estimate, Склад или Конкуренты');
|
||||
}
|
||||
// Check if online before creating
|
||||
const isOnline = await checkOnlineStatus();
|
||||
if (!isOnline) {
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 pb-12">
|
||||
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
{{template "content" .}}
|
||||
</main>
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
|
||||
<!-- Sync Info Modal -->
|
||||
<div id="sync-modal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 hidden">
|
||||
<div class="bg-white rounded-lg shadow-xl max-w-md w-full mx-4">
|
||||
<div class="bg-white rounded-lg shadow-xl max-w-lg w-full mx-4">
|
||||
<div class="p-6">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="text-lg font-medium text-gray-900">Информация о синхронизации</h3>
|
||||
@@ -57,28 +57,64 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-5">
|
||||
<!-- Section 1: DB Connection -->
|
||||
<div>
|
||||
<h4 class="font-medium text-gray-900">Статус БД</h4>
|
||||
<p id="modal-db-status" class="text-sm text-gray-600">Проверка...</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 class="font-medium text-gray-900">Количество ошибок</h4>
|
||||
<p id="modal-error-count" class="text-sm text-gray-600">0</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 class="font-medium text-gray-900">Последняя синхронизация</h4>
|
||||
<p id="modal-last-sync" class="text-sm text-gray-600">-</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 class="font-medium text-gray-900">Список ошибок</h4>
|
||||
<div id="modal-errors-list" class="mt-2 text-sm text-gray-600 max-h-40 overflow-y-auto">
|
||||
<p>Нет ошибок</p>
|
||||
<h4 class="font-medium text-gray-900 mb-2">Подключение к БД</h4>
|
||||
<div class="text-sm space-y-1">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500">Адрес:</span>
|
||||
<span id="modal-db-host" class="text-gray-700 font-mono">—</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500">Пользователь:</span>
|
||||
<span id="modal-db-user" class="text-gray-700">—</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500">Статус:</span>
|
||||
<span id="modal-db-status" class="text-gray-700">Проверка...</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500">Последняя синхронизация:</span>
|
||||
<span id="modal-last-sync" class="text-gray-700">—</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Section 2: Statistics -->
|
||||
<div>
|
||||
<h4 class="font-medium text-gray-900 mb-2">Статистика</h4>
|
||||
<div class="grid grid-cols-2 gap-2 text-sm">
|
||||
<div class="flex justify-between bg-gray-50 rounded px-3 py-1.5">
|
||||
<span class="text-gray-500">Компоненты (lot):</span>
|
||||
<span id="modal-lot-count" class="font-medium text-gray-700">—</span>
|
||||
</div>
|
||||
<div class="flex justify-between bg-gray-50 rounded px-3 py-1.5">
|
||||
<span class="text-gray-500">Котировки:</span>
|
||||
<span id="modal-lotlog-count" class="font-medium text-gray-700">—</span>
|
||||
</div>
|
||||
<div class="flex justify-between bg-gray-50 rounded px-3 py-1.5">
|
||||
<span class="text-gray-500">Конфигурации:</span>
|
||||
<span id="modal-config-count" class="font-medium text-gray-700">—</span>
|
||||
</div>
|
||||
<div class="flex justify-between bg-gray-50 rounded px-3 py-1.5">
|
||||
<span class="text-gray-500">Проекты:</span>
|
||||
<span id="modal-project-count" class="font-medium text-gray-700">—</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Section 3: Pending Changes (shown only if any) -->
|
||||
<div id="modal-pending-section" class="hidden">
|
||||
<h4 class="font-medium text-gray-900 mb-2">Ожидающие синхронизации</h4>
|
||||
<div id="modal-pending-list" class="text-sm max-h-40 overflow-y-auto space-y-1"></div>
|
||||
</div>
|
||||
|
||||
<!-- Section 4: Errors (shown only if any) -->
|
||||
<div id="modal-errors-section" class="hidden">
|
||||
<h4 class="font-medium text-gray-900 mb-2">Ошибки синхронизации</h4>
|
||||
<div id="modal-errors-list" class="text-sm max-h-40 overflow-y-auto space-y-1"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end">
|
||||
@@ -90,13 +126,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="fixed bottom-0 left-0 right-0 bg-gray-800 text-gray-300 text-xs py-1 px-4">
|
||||
<div class="max-w-7xl mx-auto flex justify-between">
|
||||
<span id="db-status">БД: проверка...</span>
|
||||
<span id="db-counts"></span>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
function showToast(msg, type) {
|
||||
const colors = { success: 'bg-green-500', error: 'bg-red-500', info: 'bg-blue-500' };
|
||||
@@ -129,37 +158,75 @@
|
||||
const resp = await fetch('/api/sync/info');
|
||||
const data = await resp.json();
|
||||
|
||||
document.getElementById('modal-db-status').textContent = data.is_online ? 'Подключено' : 'Отключено';
|
||||
document.getElementById('modal-error-count').textContent = data.error_count;
|
||||
// Section 1: DB Connection
|
||||
document.getElementById('modal-db-host').textContent = data.db_host ? data.db_host + '/' + data.db_name : '—';
|
||||
document.getElementById('modal-db-user').textContent = data.db_user || '—';
|
||||
|
||||
const statusEl = document.getElementById('modal-db-status');
|
||||
if (data.is_online) {
|
||||
statusEl.innerHTML = '<span class="inline-block w-2 h-2 rounded-full bg-green-500 mr-1"></span>Online';
|
||||
} else {
|
||||
statusEl.innerHTML = '<span class="inline-block w-2 h-2 rounded-full bg-red-500 mr-1"></span>Offline';
|
||||
}
|
||||
|
||||
if (data.last_sync_at) {
|
||||
const date = new Date(data.last_sync_at);
|
||||
document.getElementById('modal-last-sync').textContent = date.toLocaleString('ru-RU');
|
||||
} else {
|
||||
document.getElementById('modal-last-sync').textContent = 'Нет данных';
|
||||
document.getElementById('modal-last-sync').textContent = '—';
|
||||
}
|
||||
|
||||
// Load error list
|
||||
// Section 2: Statistics
|
||||
document.getElementById('modal-lot-count').textContent = data.is_online ? data.lot_count.toLocaleString() : '—';
|
||||
document.getElementById('modal-lotlog-count').textContent = data.is_online ? data.lot_log_count.toLocaleString() : '—';
|
||||
document.getElementById('modal-config-count').textContent = data.config_count.toLocaleString();
|
||||
document.getElementById('modal-project-count').textContent = data.project_count.toLocaleString();
|
||||
|
||||
// Section 3: Pending changes
|
||||
const pendingSection = document.getElementById('modal-pending-section');
|
||||
const pendingList = document.getElementById('modal-pending-list');
|
||||
if (data.pending_changes && data.pending_changes.length > 0) {
|
||||
pendingSection.classList.remove('hidden');
|
||||
pendingList.innerHTML = data.pending_changes.map(ch => {
|
||||
const shortUUID = ch.entity_uuid.substring(0, 8);
|
||||
const time = new Date(ch.created_at).toLocaleString('ru-RU');
|
||||
const hasError = ch.last_error ? ' border-l-2 border-red-400 pl-2' : '';
|
||||
const errorLine = ch.last_error ? `<div class="text-red-500 text-xs mt-0.5">${ch.last_error}</div>` : '';
|
||||
return `<div class="bg-gray-50 rounded px-3 py-1.5${hasError}">
|
||||
<span class="font-medium">${ch.operation}</span>
|
||||
<span class="text-gray-500">${ch.entity_type}</span>
|
||||
<span class="font-mono text-xs text-gray-400">${shortUUID}</span>
|
||||
<span class="text-gray-400 text-xs ml-1">${time}</span>
|
||||
${errorLine}
|
||||
</div>`;
|
||||
}).join('');
|
||||
} else {
|
||||
pendingSection.classList.add('hidden');
|
||||
}
|
||||
|
||||
// Section 4: Errors
|
||||
const errorsSection = document.getElementById('modal-errors-section');
|
||||
const errorsList = document.getElementById('modal-errors-list');
|
||||
if (data.errors && data.errors.length > 0) {
|
||||
errorsList.innerHTML = data.errors.map(error =>
|
||||
`<div class="mb-1"><span class="font-medium">${error.timestamp}</span>: ${error.message}</div>`
|
||||
).join('');
|
||||
errorsSection.classList.remove('hidden');
|
||||
errorsList.innerHTML = data.errors.map(error => {
|
||||
const time = new Date(error.timestamp).toLocaleString('ru-RU');
|
||||
return `<div class="bg-red-50 text-red-700 rounded px-3 py-1.5">
|
||||
<span class="text-xs text-red-400">${time}</span>: ${error.message}
|
||||
</div>`;
|
||||
}).join('');
|
||||
} else {
|
||||
errorsList.innerHTML = '<p>Нет ошибок</p>';
|
||||
errorsSection.classList.add('hidden');
|
||||
}
|
||||
} catch(e) {
|
||||
console.error('Failed to load sync info:', e);
|
||||
document.getElementById('modal-db-status').textContent = 'Ошибка загрузки';
|
||||
document.getElementById('modal-error-count').textContent = '0';
|
||||
document.getElementById('modal-last-sync').textContent = '-';
|
||||
document.getElementById('modal-errors-list').innerHTML = '<p>Ошибка загрузки данных</p>';
|
||||
}
|
||||
}
|
||||
|
||||
// Event delegation for sync dropdown and actions
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
checkDbStatus();
|
||||
loadDBUser();
|
||||
checkWritePermission();
|
||||
});
|
||||
|
||||
@@ -214,26 +281,16 @@
|
||||
syncAction('/api/sync/all', 'Полная синхронизация завершена', button, originalHTML);
|
||||
}
|
||||
|
||||
async function checkDbStatus() {
|
||||
async function loadDBUser() {
|
||||
try {
|
||||
const resp = await fetch('/api/db-status');
|
||||
const data = await resp.json();
|
||||
const statusEl = document.getElementById('db-status');
|
||||
const countsEl = document.getElementById('db-counts');
|
||||
const userEl = document.getElementById('db-user');
|
||||
|
||||
if (data.connected) {
|
||||
statusEl.innerHTML = '<span class="text-green-400">БД: подключено</span>';
|
||||
if (data.db_user) {
|
||||
userEl.innerHTML = '<span class="text-gray-500">@</span>' + data.db_user;
|
||||
}
|
||||
} else {
|
||||
statusEl.innerHTML = '<span class="text-red-400">БД: ошибка - ' + data.error + '</span>';
|
||||
if (data.connected && data.db_user) {
|
||||
userEl.innerHTML = '<span class="text-gray-500">@</span>' + data.db_user;
|
||||
}
|
||||
|
||||
countsEl.textContent = 'lot: ' + data.lot_count + ' | lot_log: ' + data.lot_log_count + ' | metadata: ' + data.metadata_count;
|
||||
} catch(e) {
|
||||
document.getElementById('db-status').innerHTML = '<span class="text-red-400">БД: нет связи</span>';
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
@@ -262,7 +319,7 @@
|
||||
|
||||
// Call functions immediately to ensure they run even before DOMContentLoaded
|
||||
// This ensures username and admin link are visible ASAP
|
||||
checkDbStatus();
|
||||
loadDBUser();
|
||||
checkWritePermission();
|
||||
|
||||
// Load last sync time - removed since dropdown is gone
|
||||
|
||||
@@ -246,6 +246,10 @@
|
||||
<input id="settings-disable-price-refresh" type="checkbox" class="rounded border-gray-300 text-blue-600 focus:ring-blue-500">
|
||||
<span>Не обновлять цены</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 text-sm text-gray-700">
|
||||
<input id="settings-only-in-stock" type="checkbox" class="rounded border-gray-300 text-blue-600 focus:ring-blue-500">
|
||||
<span>Только наличие</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="px-5 py-4 border-t flex justify-end gap-2">
|
||||
<button type="button" onclick="closePriceSettingsModal()" class="px-4 py-2 bg-gray-100 text-gray-700 rounded hover:bg-gray-200">Отмена</button>
|
||||
@@ -334,12 +338,19 @@ let selectedPricelistIds = {
|
||||
competitor: null
|
||||
};
|
||||
let disablePriceRefresh = false;
|
||||
let onlyInStock = false;
|
||||
let activePricelistsBySource = {
|
||||
estimate: [],
|
||||
warehouse: [],
|
||||
competitor: []
|
||||
};
|
||||
let activePricelistsLoadedAt = 0;
|
||||
let activePricelistsLoadPromise = null;
|
||||
let priceLevelsRequestSeq = 0;
|
||||
let priceLevelsRefreshTimer = null;
|
||||
let warehouseStockLotsByPricelist = new Map();
|
||||
let warehouseStockLoadSeq = 0;
|
||||
let warehouseStockLoadsByPricelist = new Map();
|
||||
|
||||
// Autocomplete state
|
||||
let autocompleteInput = null;
|
||||
@@ -389,8 +400,10 @@ function formatDelta(abs, pct) {
|
||||
return sign + formatMoney(absValue) + ' (' + pctSign + Math.round(Math.abs(pct)) + '%)';
|
||||
}
|
||||
|
||||
async function refreshPriceLevels() {
|
||||
if (!configUUID || cart.length === 0 || disablePriceRefresh) {
|
||||
async function refreshPriceLevels(options = {}) {
|
||||
const force = options.force === true;
|
||||
const noCache = options.noCache === true;
|
||||
if (!configUUID || cart.length === 0 || (disablePriceRefresh && !force)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -401,6 +414,7 @@ async function refreshPriceLevels() {
|
||||
lot_name: item.lot_name,
|
||||
quantity: item.quantity
|
||||
})),
|
||||
no_cache: noCache,
|
||||
pricelist_ids: Object.fromEntries(
|
||||
Object.entries(selectedPricelistIds)
|
||||
.filter(([, id]) => typeof id === 'number' && id > 0)
|
||||
@@ -443,6 +457,99 @@ async function refreshPriceLevels() {
|
||||
}
|
||||
}
|
||||
|
||||
function schedulePriceLevelsRefresh(options = {}) {
|
||||
const delay = Number.isFinite(options.delay) ? options.delay : 120;
|
||||
const rerender = options.rerender !== false;
|
||||
const autosave = options.autosave === true;
|
||||
const noCache = options.noCache === true;
|
||||
const force = options.force === true;
|
||||
|
||||
if (priceLevelsRefreshTimer) {
|
||||
clearTimeout(priceLevelsRefreshTimer);
|
||||
priceLevelsRefreshTimer = null;
|
||||
}
|
||||
|
||||
priceLevelsRefreshTimer = setTimeout(async () => {
|
||||
priceLevelsRefreshTimer = null;
|
||||
await refreshPriceLevels({ noCache, force });
|
||||
if (rerender) {
|
||||
renderTab();
|
||||
updateCartUI();
|
||||
}
|
||||
if (autosave) {
|
||||
triggerAutoSave();
|
||||
}
|
||||
}, Math.max(0, delay));
|
||||
}
|
||||
|
||||
function currentWarehousePricelistID() {
|
||||
const id = selectedPricelistIds.warehouse;
|
||||
if (Number.isFinite(id) && id > 0) return Number(id);
|
||||
const fallback = activePricelistsBySource.warehouse?.[0]?.id;
|
||||
if (Number.isFinite(fallback) && fallback > 0) return Number(fallback);
|
||||
return null;
|
||||
}
|
||||
|
||||
async function loadWarehouseInStockLots() {
|
||||
const pricelistID = currentWarehousePricelistID();
|
||||
if (!pricelistID) return new Set();
|
||||
if (warehouseStockLotsByPricelist.has(pricelistID)) {
|
||||
return warehouseStockLotsByPricelist.get(pricelistID);
|
||||
}
|
||||
const existingLoad = warehouseStockLoadsByPricelist.get(pricelistID);
|
||||
if (existingLoad) {
|
||||
return existingLoad;
|
||||
}
|
||||
|
||||
const loadPromise = (async () => {
|
||||
const seq = ++warehouseStockLoadSeq;
|
||||
const result = new Set();
|
||||
const resp = await fetch(`/api/pricelists/${pricelistID}/lots`);
|
||||
if (!resp.ok) {
|
||||
throw new Error(`warehouse lots request failed: ${resp.status}`);
|
||||
}
|
||||
const data = await resp.json();
|
||||
const lotNames = Array.isArray(data.lot_names) ? data.lot_names : [];
|
||||
lotNames.forEach(lot => {
|
||||
if (typeof lot === 'string' && lot.trim() !== '') {
|
||||
result.add(lot);
|
||||
}
|
||||
});
|
||||
|
||||
if (seq === warehouseStockLoadSeq) {
|
||||
warehouseStockLotsByPricelist.set(pricelistID, result);
|
||||
}
|
||||
return result;
|
||||
})();
|
||||
|
||||
warehouseStockLoadsByPricelist.set(pricelistID, loadPromise);
|
||||
try {
|
||||
return await loadPromise;
|
||||
} finally {
|
||||
warehouseStockLoadsByPricelist.delete(pricelistID);
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureWarehouseStockFilterLoaded() {
|
||||
if (!onlyInStock) return;
|
||||
try {
|
||||
await loadWarehouseInStockLots();
|
||||
} catch (e) {
|
||||
console.error('Failed to load warehouse availability filter', e);
|
||||
showToast('Не удалось загрузить наличие склада', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function isComponentAllowedByStockFilter(comp) {
|
||||
if (!onlyInStock) return true;
|
||||
const pricelistID = currentWarehousePricelistID();
|
||||
if (!pricelistID) return false;
|
||||
const availableLots = warehouseStockLotsByPricelist.get(pricelistID);
|
||||
// Don't block UI while stock set is being loaded.
|
||||
if (!availableLots) return true;
|
||||
return availableLots.has(comp.lot_name);
|
||||
}
|
||||
|
||||
// Load categories from API and update tab configuration
|
||||
async function loadCategoriesFromAPI() {
|
||||
try {
|
||||
@@ -486,8 +593,8 @@ document.addEventListener('DOMContentLoaded', async function() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Load categories first
|
||||
await loadCategoriesFromAPI();
|
||||
// Load categories in background (defaults are usable immediately).
|
||||
const categoriesPromise = loadCategoriesFromAPI().catch(() => {});
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/configs/' + configUUID);
|
||||
@@ -508,6 +615,7 @@ document.addEventListener('DOMContentLoaded', async function() {
|
||||
document.getElementById('server-count').value = serverCount;
|
||||
document.getElementById('total-server-count').textContent = serverCount;
|
||||
selectedPricelistIds.estimate = config.pricelist_id || null;
|
||||
onlyInStock = Boolean(config.only_in_stock);
|
||||
|
||||
if (config.items && config.items.length > 0) {
|
||||
cart = config.items.map(item => ({
|
||||
@@ -538,14 +646,21 @@ document.addEventListener('DOMContentLoaded', async function() {
|
||||
}
|
||||
|
||||
restoreLocalPriceSettings();
|
||||
await loadActivePricelists();
|
||||
await Promise.all([
|
||||
loadActivePricelists(),
|
||||
loadAllComponents(),
|
||||
categoriesPromise,
|
||||
]);
|
||||
syncPriceSettingsControls();
|
||||
renderPricelistSettingsSummary();
|
||||
updateRefreshPricesButtonState();
|
||||
await loadAllComponents();
|
||||
await refreshPriceLevels();
|
||||
renderTab();
|
||||
updateCartUI();
|
||||
ensureWarehouseStockFilterLoaded().then(() => {
|
||||
renderTab();
|
||||
updateCartUI();
|
||||
});
|
||||
schedulePriceLevelsRefresh({ delay: 0, rerender: true, autosave: false });
|
||||
|
||||
// Close autocomplete on outside click
|
||||
document.addEventListener('click', function(e) {
|
||||
@@ -582,25 +697,44 @@ function updateServerCount() {
|
||||
triggerAutoSave();
|
||||
}
|
||||
|
||||
async function loadActivePricelists() {
|
||||
async function loadActivePricelists(force = false) {
|
||||
const now = Date.now();
|
||||
const isFresh = (now - activePricelistsLoadedAt) < 15000;
|
||||
if (!force && isFresh) {
|
||||
return;
|
||||
}
|
||||
if (activePricelistsLoadPromise) {
|
||||
await activePricelistsLoadPromise;
|
||||
return;
|
||||
}
|
||||
|
||||
const sources = ['estimate', 'warehouse', 'competitor'];
|
||||
await Promise.all(sources.map(async source => {
|
||||
try {
|
||||
const resp = await fetch(`/api/pricelists?active_only=true&source=${source}&per_page=200`);
|
||||
const data = await resp.json();
|
||||
activePricelistsBySource[source] = data.pricelists || [];
|
||||
const existing = selectedPricelistIds[source];
|
||||
if (existing && activePricelistsBySource[source].some(pl => Number(pl.id) === Number(existing))) {
|
||||
return;
|
||||
activePricelistsLoadPromise = (async () => {
|
||||
await Promise.all(sources.map(async source => {
|
||||
try {
|
||||
const resp = await fetch(`/api/pricelists?active_only=true&source=${source}&per_page=200`);
|
||||
const data = await resp.json();
|
||||
activePricelistsBySource[source] = data.pricelists || [];
|
||||
const existing = selectedPricelistIds[source];
|
||||
if (existing && activePricelistsBySource[source].some(pl => Number(pl.id) === Number(existing))) {
|
||||
return;
|
||||
}
|
||||
selectedPricelistIds[source] = activePricelistsBySource[source].length > 0
|
||||
? Number(activePricelistsBySource[source][0].id)
|
||||
: null;
|
||||
} catch (e) {
|
||||
activePricelistsBySource[source] = [];
|
||||
selectedPricelistIds[source] = null;
|
||||
}
|
||||
selectedPricelistIds[source] = activePricelistsBySource[source].length > 0
|
||||
? Number(activePricelistsBySource[source][0].id)
|
||||
: null;
|
||||
} catch (e) {
|
||||
activePricelistsBySource[source] = [];
|
||||
selectedPricelistIds[source] = null;
|
||||
}
|
||||
}));
|
||||
}));
|
||||
activePricelistsLoadedAt = Date.now();
|
||||
})();
|
||||
|
||||
try {
|
||||
await activePricelistsLoadPromise;
|
||||
} finally {
|
||||
activePricelistsLoadPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
function renderPricelistSelectOptions(selectId, source) {
|
||||
@@ -627,6 +761,10 @@ function syncPriceSettingsControls() {
|
||||
if (disableCheckbox) {
|
||||
disableCheckbox.checked = disablePriceRefresh;
|
||||
}
|
||||
const inStockCheckbox = document.getElementById('settings-only-in-stock');
|
||||
if (inStockCheckbox) {
|
||||
inStockCheckbox.checked = onlyInStock;
|
||||
}
|
||||
}
|
||||
|
||||
function getPricelistVersionById(source, id) {
|
||||
@@ -642,7 +780,8 @@ function renderPricelistSettingsSummary() {
|
||||
const warehouse = selectedPricelistIds.warehouse ? getPricelistVersionById('warehouse', selectedPricelistIds.warehouse) || `ID ${selectedPricelistIds.warehouse}` : 'авто';
|
||||
const competitor = selectedPricelistIds.competitor ? getPricelistVersionById('competitor', selectedPricelistIds.competitor) || `ID ${selectedPricelistIds.competitor}` : 'авто';
|
||||
const refreshState = disablePriceRefresh ? ' | Обновление цен: выкл' : '';
|
||||
summary.textContent = `Estimate: ${estimate}, Склад: ${warehouse}, Конкуренты: ${competitor}${refreshState}`;
|
||||
const stockFilterState = onlyInStock ? ' | Только наличие: вкл' : '';
|
||||
summary.textContent = `Estimate: ${estimate}, Склад: ${warehouse}, Конкуренты: ${competitor}${refreshState}${stockFilterState}`;
|
||||
}
|
||||
|
||||
function updateRefreshPricesButtonState() {
|
||||
@@ -693,11 +832,14 @@ function restoreLocalPriceSettings() {
|
||||
}
|
||||
}
|
||||
|
||||
async function openPriceSettingsModal() {
|
||||
await loadActivePricelists();
|
||||
function openPriceSettingsModal() {
|
||||
syncPriceSettingsControls();
|
||||
renderPricelistSettingsSummary();
|
||||
document.getElementById('price-settings-modal')?.classList.remove('hidden');
|
||||
loadActivePricelists().then(() => {
|
||||
syncPriceSettingsControls();
|
||||
renderPricelistSettingsSummary();
|
||||
});
|
||||
}
|
||||
|
||||
function closePriceSettingsModal() {
|
||||
@@ -709,22 +851,31 @@ function applyPriceSettings() {
|
||||
const warehouseVal = parseInt(document.getElementById('settings-pricelist-warehouse')?.value || '');
|
||||
const competitorVal = parseInt(document.getElementById('settings-pricelist-competitor')?.value || '');
|
||||
const disableVal = Boolean(document.getElementById('settings-disable-price-refresh')?.checked);
|
||||
const inStockVal = Boolean(document.getElementById('settings-only-in-stock')?.checked);
|
||||
|
||||
const prevWarehouseID = currentWarehousePricelistID();
|
||||
selectedPricelistIds.estimate = Number.isFinite(estimateVal) && estimateVal > 0 ? estimateVal : null;
|
||||
selectedPricelistIds.warehouse = Number.isFinite(warehouseVal) && warehouseVal > 0 ? warehouseVal : null;
|
||||
selectedPricelistIds.competitor = Number.isFinite(competitorVal) && competitorVal > 0 ? competitorVal : null;
|
||||
disablePriceRefresh = disableVal;
|
||||
onlyInStock = inStockVal;
|
||||
|
||||
const nextWarehouseID = currentWarehousePricelistID();
|
||||
if (Number.isFinite(prevWarehouseID) && prevWarehouseID > 0 && prevWarehouseID !== nextWarehouseID) {
|
||||
warehouseStockLotsByPricelist.delete(prevWarehouseID);
|
||||
}
|
||||
if (onlyInStock) {
|
||||
ensureWarehouseStockFilterLoaded().then(() => {
|
||||
renderTab();
|
||||
updateCartUI();
|
||||
});
|
||||
}
|
||||
|
||||
updateRefreshPricesButtonState();
|
||||
renderPricelistSettingsSummary();
|
||||
persistLocalPriceSettings();
|
||||
closePriceSettingsModal();
|
||||
|
||||
refreshPriceLevels().then(() => {
|
||||
renderTab();
|
||||
updateCartUI();
|
||||
triggerAutoSave();
|
||||
});
|
||||
schedulePriceLevelsRefresh({ delay: 0, rerender: true, autosave: true });
|
||||
}
|
||||
|
||||
function getCategoryFromLotName(lotName) {
|
||||
@@ -1065,6 +1216,7 @@ function filterAutocomplete(category, search) {
|
||||
|
||||
autocompleteFiltered = components.filter(c => {
|
||||
if (!c.current_price) return false;
|
||||
if (!isComponentAllowedByStockFilter(c)) return false;
|
||||
const text = (c.lot_name + ' ' + (c.description || '')).toLowerCase();
|
||||
return text.includes(searchLower);
|
||||
})
|
||||
@@ -1166,11 +1318,10 @@ function selectAutocompleteItem(index) {
|
||||
});
|
||||
|
||||
hideAutocomplete();
|
||||
refreshPriceLevels().then(() => {
|
||||
renderTab();
|
||||
updateCartUI();
|
||||
triggerAutoSave();
|
||||
});
|
||||
renderTab();
|
||||
updateCartUI();
|
||||
triggerAutoSave();
|
||||
schedulePriceLevelsRefresh({ delay: 80, rerender: true, autosave: false });
|
||||
}
|
||||
|
||||
function hideAutocomplete() {
|
||||
@@ -1200,6 +1351,7 @@ function filterAutocompleteMulti(search) {
|
||||
autocompleteFiltered = components.filter(c => {
|
||||
if (!c.current_price) return false;
|
||||
if (addedLots.has(c.lot_name)) return false;
|
||||
if (!isComponentAllowedByStockFilter(c)) return false;
|
||||
const text = (c.lot_name + ' ' + (c.description || '')).toLowerCase();
|
||||
return text.includes(searchLower);
|
||||
})
|
||||
@@ -1258,11 +1410,10 @@ function selectAutocompleteItemMulti(index) {
|
||||
});
|
||||
|
||||
hideAutocomplete();
|
||||
refreshPriceLevels().then(() => {
|
||||
renderTab();
|
||||
updateCartUI();
|
||||
triggerAutoSave();
|
||||
});
|
||||
renderTab();
|
||||
updateCartUI();
|
||||
triggerAutoSave();
|
||||
schedulePriceLevelsRefresh({ delay: 80, rerender: true, autosave: false });
|
||||
}
|
||||
|
||||
// Autocomplete for sectioned tabs (like storage with RAID and Disks sections)
|
||||
@@ -1299,6 +1450,7 @@ function filterAutocompleteSection(sectionId, search, inputElement) {
|
||||
autocompleteFiltered = sectionComponents.filter(c => {
|
||||
if (!c.current_price) return false;
|
||||
if (addedLots.has(c.lot_name)) return false;
|
||||
if (!isComponentAllowedByStockFilter(c)) return false;
|
||||
const text = (c.lot_name + ' ' + (c.description || '')).toLowerCase();
|
||||
return text.includes(searchLower);
|
||||
})
|
||||
@@ -1364,12 +1516,10 @@ function selectAutocompleteItemSection(index, sectionId) {
|
||||
|
||||
// Reset quantity to 1
|
||||
if (qtyInput) qtyInput.value = '1';
|
||||
|
||||
refreshPriceLevels().then(() => {
|
||||
renderTab();
|
||||
updateCartUI();
|
||||
triggerAutoSave();
|
||||
});
|
||||
renderTab();
|
||||
updateCartUI();
|
||||
triggerAutoSave();
|
||||
schedulePriceLevelsRefresh({ delay: 80, rerender: true, autosave: false });
|
||||
}
|
||||
|
||||
function clearSingleSelect(category) {
|
||||
@@ -1517,6 +1667,12 @@ function triggerAutoSave() {
|
||||
async function saveConfig(showNotification = true) {
|
||||
// RBAC disabled - no token check required
|
||||
if (!configUUID) return;
|
||||
if (priceLevelsRefreshTimer) {
|
||||
clearTimeout(priceLevelsRefreshTimer);
|
||||
priceLevelsRefreshTimer = null;
|
||||
}
|
||||
|
||||
await refreshPriceLevels({ force: true, noCache: true });
|
||||
|
||||
// Get custom price if set
|
||||
const customPriceInput = document.getElementById('custom-price-input');
|
||||
@@ -1538,7 +1694,8 @@ async function saveConfig(showNotification = true) {
|
||||
custom_price: customPrice,
|
||||
notes: '',
|
||||
server_count: serverCountValue,
|
||||
pricelist_id: selectedPricelistIds.estimate
|
||||
pricelist_id: selectedPricelistIds.estimate,
|
||||
only_in_stock: onlyInStock
|
||||
})
|
||||
});
|
||||
|
||||
@@ -1563,10 +1720,20 @@ async function exportCSV() {
|
||||
if (cart.length === 0) return;
|
||||
|
||||
try {
|
||||
if (priceLevelsRefreshTimer) {
|
||||
clearTimeout(priceLevelsRefreshTimer);
|
||||
priceLevelsRefreshTimer = null;
|
||||
}
|
||||
await refreshPriceLevels({ force: true, noCache: true });
|
||||
|
||||
const exportItems = cart.map(item => ({
|
||||
...item,
|
||||
unit_price: getDisplayPrice(item),
|
||||
}));
|
||||
const resp = await fetch('/api/export/csv', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({items: cart, name: configName})
|
||||
body: JSON.stringify({items: exportItems, name: configName})
|
||||
});
|
||||
|
||||
const blob = await resp.blob();
|
||||
@@ -1793,6 +1960,11 @@ function clearCustomPrice() {
|
||||
|
||||
async function exportCSVWithCustomPrice() {
|
||||
if (cart.length === 0) return;
|
||||
if (priceLevelsRefreshTimer) {
|
||||
clearTimeout(priceLevelsRefreshTimer);
|
||||
priceLevelsRefreshTimer = null;
|
||||
}
|
||||
await refreshPriceLevels({ force: true, noCache: true });
|
||||
|
||||
const customPrice = parseFloat(document.getElementById('custom-price-input').value) || 0;
|
||||
const originalTotal = cart.reduce((sum, item) => sum + (getDisplayPrice(item) * item.quantity), 0);
|
||||
|
||||
@@ -169,6 +169,16 @@
|
||||
return qty.toLocaleString('ru-RU', { minimumFractionDigits: 0, maximumFractionDigits: 3 });
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
if (text === null || text === undefined) return '';
|
||||
return String(text)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function formatPriceSettings(item) {
|
||||
// Format price settings to match admin pricing interface style
|
||||
let settings = [];
|
||||
|
||||
Reference in New Issue
Block a user