Implement warehouse/lot pricing updates and configurator performance fixes

This commit is contained in:
2026-02-07 05:20:35 +03:00
parent c1a31e5ee0
commit 7c741ff675
26 changed files with 1701 additions and 305 deletions

View File

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