Files
QuoteForge/web/templates/admin_pricing.html

2180 lines
106 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{{define "title"}}Цены - QuoteForge{{end}}
{{define "content"}}
<div class="space-y-4">
<h1 class="text-2xl font-bold">Управление ценами</h1>
<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('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>
<button onclick="loadTab('sync-status')" id="btn-sync-status" class="text-gray-600 hidden">Статус синхронизации</button>
<button onclick="loadTab('all-configs')" id="btn-all-configs" class="text-gray-600 hidden">Все конфигурации</button>
</div>
<button onclick="recalculateAll()" id="btn-recalc" class="px-3 py-1 bg-green-600 text-white text-sm rounded hover:bg-green-700">
Обновить цены
</button>
</div>
<!-- Progress bar -->
<div id="progress-container" class="mb-4 p-4 bg-blue-50 rounded-lg border border-blue-200" style="display:none;">
<div class="flex justify-between text-sm text-gray-700 mb-2">
<span id="progress-text" class="font-medium">Обновление цен...</span>
<span id="progress-percent" class="font-bold">0%</span>
</div>
<div class="w-full bg-gray-200 rounded-full h-4">
<div id="progress-bar" class="bg-blue-600 h-4 rounded-full transition-all duration-300" style="width: 0%"></div>
</div>
<div class="text-sm text-gray-600 mt-2">
<span id="progress-stats"></span>
</div>
</div>
<!-- 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="Поиск..."
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="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>
</div>
</div>
</div>
<div id="tab-content">
<div class="text-center py-8 text-gray-500">Загрузка...</div>
</div>
<!-- Pricelists Tab Content (hidden by default) -->
<div id="pricelists-tab-content" class="hidden">
<div class="flex justify-between items-center mb-4">
<div class="flex items-center gap-3">
<h2 id="pricelists-title" class="text-xl font-semibold">Estimate</h2>
<button id="estimate-settings-btn" onclick="loadTab('component-settings')" class="hidden px-3 py-2 border border-gray-300 rounded-md text-sm hover:bg-gray-50">
Настройка компонентов
</button>
</div>
<div class="flex items-center gap-3">
<div id="pricelists-create-btn-container"></div>
</div>
</div>
<div class="bg-white rounded-lg shadow overflow-hidden">
<table class="min-w-full divide-y divide-gray-200">
<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>
<th class="px-6 py-3 text-center 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>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">Действия</th>
</tr>
</thead>
<tbody id="pricelists-body" class="bg-white divide-y divide-gray-200">
<tr>
<td colspan="8" class="px-6 py-4 text-center text-gray-500">Загрузка...</td>
</tr>
</tbody>
</table>
</div>
<div id="pricelists-pagination" class="flex justify-center space-x-2 mt-4"></div>
<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>
</div>
<div id="stock-import-progress" class="hidden mt-4 p-3 bg-blue-50 border border-blue-200 rounded">
<div class="flex justify-between text-sm mb-1">
<span id="stock-import-status">Запуск...</span>
<span id="stock-import-percent">0%</span>
</div>
<div class="w-full bg-blue-100 rounded-full h-3">
<div id="stock-import-bar" class="h-3 bg-blue-600 rounded-full transition-all duration-300" style="width:0%"></div>
</div>
<div id="stock-import-stats" class="text-xs text-gray-600 mt-2"></div>
</div>
<div id="stock-import-suggestions" class="hidden mt-4 p-3 bg-amber-50 border border-amber-200 rounded">
<div class="flex items-center justify-between mb-2">
<div class="text-sm font-medium text-amber-900">Новые партномера без сопоставления</div>
<div class="flex items-center gap-2">
<button id="btn-add-all-suggestions" onclick="addAllSuggestions()" class="inline-flex items-center gap-1 px-2 py-1 text-xs rounded border border-blue-200 text-blue-700 hover:bg-blue-50" title="Добавить все">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
</svg>
<span>Добавить все</span>
</button>
<button id="btn-ignore-all-suggestions" onclick="ignoreAllSuggestions()" class="inline-flex items-center gap-1 px-2 py-1 text-xs rounded border border-amber-200 text-amber-700 hover:bg-amber-100" title="Игнорировать все">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 5.636l-12.728 12.728M5.636 5.636l12.728 12.728"></path>
</svg>
<span>Игнорировать все</span>
</button>
</div>
</div>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-amber-200">
<thead class="bg-amber-100">
<tr>
<th class="px-3 py-2 w-1/4 text-left text-xs font-medium text-amber-800 uppercase">LOT</th>
<th class="px-3 py-2 w-1/4 text-left text-xs font-medium text-amber-800 uppercase">Partnumber</th>
<th class="px-3 py-2 text-left text-xs font-medium text-amber-800 uppercase">Описание</th>
<th class="px-3 py-2 text-left text-xs font-medium text-amber-800 uppercase">Причина</th>
<th class="px-3 py-2 text-right text-xs font-medium text-amber-800 uppercase">Действия</th>
</tr>
</thead>
<tbody id="stock-import-suggestions-body" class="bg-white divide-y divide-amber-100"></tbody>
</table>
</div>
</div>
</div>
<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">
<datalist id="stock-lot-options"></datalist>
<button onclick="loadStockMappings(1)" class="px-3 py-2 border rounded hover:bg-gray-50">Обновить</button>
</div>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-2 w-1/4 text-left text-xs font-medium text-gray-500 uppercase">LOT</th>
<th class="px-4 py-2 w-1/4 text-left text-xs font-medium text-gray-500 uppercase">Partnumber</th>
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Описание</th>
<th class="px-4 py-2 text-right text-xs font-medium text-gray-500 uppercase">Действия</th>
</tr>
</thead>
<tbody id="stock-mappings-body" class="bg-white divide-y divide-gray-200">
<tr><td colspan="4" class="px-4 py-3 text-sm text-gray-500">Загрузка...</td></tr>
</tbody>
</table>
</div>
<div id="stock-mappings-pagination" class="flex justify-center space-x-2 mt-3"></div>
</div>
<div class="border rounded-lg p-4">
<h3 class="text-lg font-semibold mb-3">Игнорирование при импорте</h3>
<div class="flex items-center gap-2 mb-3">
<select id="ignore-target" class="px-3 py-2 border rounded">
<option value="partnumber">Partnumber</option>
<option value="description">Описание</option>
</select>
<select id="ignore-match-type" class="px-3 py-2 border rounded">
<option value="exact">Равно</option>
<option value="prefix">Начинается с</option>
<option value="suffix">Заканчивается на</option>
</select>
<input id="ignore-pattern" type="text" placeholder="Шаблон" class="px-3 py-2 border rounded w-1/3">
<button onclick="saveStockIgnoreRule()" class="px-3 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">Добавить</button>
<button onclick="loadStockIgnoreRules(1)" class="px-3 py-2 border rounded hover:bg-gray-50">Обновить</button>
</div>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Поле</th>
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Тип</th>
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Шаблон</th>
<th class="px-4 py-2 text-right text-xs font-medium text-gray-500 uppercase">Действия</th>
</tr>
</thead>
<tbody id="stock-ignore-rules-body" class="bg-white divide-y divide-gray-200">
<tr><td colspan="4" class="px-4 py-3 text-sm text-gray-500">Загрузка...</td></tr>
</tbody>
</table>
</div>
<div id="stock-ignore-rules-pagination" class="flex justify-center space-x-2 mt-3"></div>
</div>
</div>
</div>
<!-- Sync Status Tab Content (hidden by default) -->
<div id="sync-status-tab-content" class="hidden">
<div class="mb-4">
<h2 class="text-xl font-semibold">Статус синхронизации</h2>
</div>
<div class="bg-white rounded-lg shadow overflow-hidden">
<table class="min-w-full divide-y divide-gray-200">
<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>
</tr>
</thead>
<tbody id="sync-users-status-body" class="bg-white divide-y divide-gray-200">
<tr>
<td colspan="3" class="px-6 py-4 text-sm text-gray-500">Загрузка...</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Create Modal -->
<div id="pricelists-create-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
<div class="bg-white rounded-lg p-6 max-w-md w-full mx-4">
<h2 class="text-xl font-bold mb-4">Создать прайслист</h2>
<p class="text-sm text-gray-600 mb-4">
Будет создан снимок текущих цен из базы данных.<br>
Автор прайслиста: <span id="pricelists-db-username" class="font-medium">загрузка...</span>
</p>
<form id="pricelists-create-form" class="space-y-4">
<div id="pricelist-create-progress" class="hidden p-3 bg-blue-50 rounded-lg border border-blue-200">
<div class="flex justify-between items-center text-sm mb-2">
<span id="pricelist-create-progress-text" class="font-medium">Подготовка...</span>
<span id="pricelist-create-progress-percent" class="font-bold">0%</span>
</div>
<div class="w-full bg-blue-100 rounded-full h-3 overflow-hidden">
<div id="pricelist-create-progress-bar" class="bg-blue-600 h-3 rounded-full transition-all duration-300" style="width: 0%"></div>
</div>
<div id="pricelist-create-progress-stats" class="text-xs text-gray-600 mt-2"></div>
</div>
<div class="flex justify-end space-x-3">
<button type="button" onclick="closePricelistsCreateModal()"
class="px-4 py-2 text-gray-700 border border-gray-300 rounded-md hover:bg-gray-50">
Отмена
</button>
<button type="submit"
class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700">
Создать
</button>
</div>
</form>
</div>
</div>
<!-- Pagination -->
<div id="pagination" class="flex justify-between items-center mt-4 pt-4 border-t hidden">
<span id="page-info" class="text-sm text-gray-600"></span>
<div class="flex gap-2">
<button onclick="prevPage()" id="btn-prev" class="px-3 py-1 border rounded text-sm disabled:opacity-50">Назад</button>
<button onclick="nextPage()" id="btn-next" class="px-3 py-1 border rounded text-sm disabled:opacity-50">Вперед</button>
</div>
</div>
</div>
</div>
<!-- Price Settings Modal -->
<div id="price-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
<div class="bg-white rounded-lg shadow-xl w-full max-w-md mx-4">
<div class="flex justify-between items-center p-4 border-b">
<h3 class="text-lg font-semibold">Настройка цены</h3>
<button onclick="closeModal()" class="text-gray-500 hover:text-gray-700">&times;</button>
</div>
<div class="p-4 space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Артикул</label>
<input type="text" id="modal-lot-name" readonly class="w-full px-3 py-2 border rounded bg-gray-100">
</div>
<div class="flex items-center mb-2">
<input type="checkbox" id="modal-meta-enabled" class="mr-2" onchange="toggleMetaPrice()">
<span class="text-sm font-medium text-gray-700">Мета артикул</span>
</div>
<div id="meta-price-fields" class="hidden mt-2">
<label class="block text-sm font-medium text-gray-700 mb-1">Источники цен</label>
<input type="text" id="modal-meta-prices" class="w-full px-3 py-2 border rounded" placeholder="Артикулы через запятую (например: CPU_AMD_9654, MB_INTEL_4.Sapphire_2S)">
<p class="text-xs text-gray-500 mt-1">Артикулы, чьи цены будут использоваться в расчете. Для автоматического подбора используйте * в конце (например: CPU_AMD_9654*)</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Метод расчёта</label>
<select id="modal-method" class="w-full px-3 py-2 border rounded" onchange="onMethodChange()">
<option value="median">Медиана</option>
<option value="average">Среднее</option>
<option value="manual">Установить цену вручную</option>
</select>
</div>
<div id="manual-price-field" class="hidden">
<label class="block text-sm font-medium text-gray-700 mb-1">Ручная цена (USD)</label>
<input type="number" id="modal-manual-price" step="0.01" class="w-full px-3 py-2 border rounded" placeholder="Цена USD">
<p class="text-xs text-gray-500 mt-1">Ручная цена сохраняется при пересчёте</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Период расчёта</label>
<select id="modal-period" class="w-full px-3 py-2 border rounded">
<option value="7">1 неделя</option>
<option value="30">1 месяц</option>
<option value="90">1 квартал</option>
<option value="365">1 год</option>
<option value="0">Всё время</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Коэффициент корректировки (%)</label>
<input type="number" id="modal-coefficient" step="1" class="w-full px-3 py-2 border rounded" placeholder="0">
<p class="text-xs text-gray-500 mt-1">Например: 30 для +30%, -10 для -10%</p>
</div>
<div class="flex items-center pt-2 border-t">
<input type="checkbox" id="modal-hidden" class="mr-2">
<span class="text-sm font-medium text-gray-700">Скрыть из конфигуратора</span>
</div>
<div class="bg-gray-50 p-3 rounded space-y-2">
<div class="text-sm font-medium text-gray-700 mb-2">Расчёт цены</div>
<div class="grid grid-cols-2 gap-2 text-sm">
<div class="text-gray-600">Последняя цена:</div>
<div id="modal-last-price" class="font-medium text-right"></div>
<div class="text-gray-600">Медиана (всё время):</div>
<div id="modal-median-all" class="font-medium text-right"></div>
<div class="text-gray-600">Текущая цена:</div>
<div id="modal-current-price" class="font-medium text-right"></div>
<div class="text-gray-600 font-medium text-blue-600">Новая цена:</div>
<div id="modal-new-price" class="font-bold text-right text-blue-600"></div>
</div>
<div class="text-xs text-gray-500 pt-2 border-t">
Кол-во котировок: <span id="modal-quote-count"></span>
</div>
</div>
</div>
<div class="flex justify-end gap-2 p-4 border-t">
<button onclick="closeModal()" class="px-4 py-2 border rounded hover:bg-gray-50">Отмена</button>
<button onclick="savePrice()" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">Сохранить</button>
</div>
</div>
</div>
<script>
let currentTab = 'lots';
let currentPricelistSource = 'estimate';
let currentPage = 1;
let totalPages = 1;
let perPage = 50;
let searchTimeout = null;
let currentSearch = '';
let componentsCache = [];
let sortField = 'lot_name';
let sortDir = 'asc';
let pricelistsPage = 1;
let pricelistsCanWrite = false;
let isCreatingPricelist = false;
let cachedDbUsername = null;
let syncUsersStatusTimer = null;
let stockMappingsPage = 1;
let stockIgnoreRulesPage = 1;
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;
currentSearch = '';
document.getElementById('search-input').value = '';
if (tab !== 'sync-status') {
stopSyncUsersStatusRefresh();
}
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 === '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 = 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 (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';
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 === 'sync-status') {
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 = 'hidden';
document.getElementById('sync-status-tab-content').className = '';
document.getElementById('tab-content').className = 'hidden';
await checkPricelistWritePermission();
if (!pricelistsCanWrite) {
await loadTab('lots');
return;
}
await loadUsersSyncStatus();
startSyncUsersStatusRefresh();
} else if (tab === 'all-configs') {
document.getElementById('search-bar').className = 'mb-4 hidden';
document.getElementById('pagination').className = 'flex justify-between items-center mt-4 pt-4 border-t';
document.getElementById('btn-all-configs').className = 'text-blue-600 font-medium';
document.getElementById('pricelists-tab-content').className = 'hidden';
document.getElementById('sync-status-tab-content').className = 'hidden';
document.getElementById('tab-content').className = '';
await loadData();
} else {
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 = 'hidden';
document.getElementById('sync-status-tab-content').className = 'hidden';
document.getElementById('tab-content').className = '';
await loadData();
}
}
function stopSyncUsersStatusRefresh() {
if (syncUsersStatusTimer) {
clearInterval(syncUsersStatusTimer);
syncUsersStatusTimer = null;
}
}
function startSyncUsersStatusRefresh() {
stopSyncUsersStatusRefresh();
syncUsersStatusTimer = setInterval(() => {
if (currentTab === 'sync-status' && pricelistsCanWrite) {
loadUsersSyncStatus();
}
}, 30000);
}
async function loadData() {
document.getElementById('tab-content').innerHTML = '<div class="text-center py-8 text-gray-500">Загрузка...</div>';
try {
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();
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;
if (currentSearch) {
url += '&search=' + encodeURIComponent(currentSearch);
}
const resp = await fetch(url);
const data = await resp.json();
totalPages = Math.ceil(data.total / perPage);
renderAllConfigs(data.configurations || []);
updatePagination(data.total);
} else if (currentTab === 'component-settings') {
let url = '/api/admin/pricing/components?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();
totalPages = Math.ceil(data.total / perPage);
componentsCache = data.components || [];
renderComponents(componentsCache, data.total);
updatePagination(data.total);
} else {
document.getElementById('tab-content').innerHTML = '<div class="text-center py-8 text-gray-500">Нет данных для вкладки</div>';
}
} catch(e) {
document.getElementById('tab-content').innerHTML = '<div class="text-center py-8 text-red-600">Ошибка загрузки</div>';
}
}
function debounceSearch() {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
currentSearch = document.getElementById('search-input').value;
currentPage = 1;
loadData();
}, 300);
}
function prevPage() {
if (currentPage > 1) {
currentPage--;
loadData();
}
}
function nextPage() {
if (currentPage < totalPages) {
currentPage++;
loadData();
}
}
function updatePagination(total) {
document.getElementById('page-info').textContent =
'Страница ' + currentPage + ' из ' + totalPages + ' (всего: ' + total + ')';
document.getElementById('btn-prev').disabled = currentPage <= 1;
document.getElementById('btn-next').disabled = currentPage >= totalPages;
}
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="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 += '</tbody></table></div>';
document.getElementById('tab-content').innerHTML = html;
}
function renderComponents(components, total) {
if (components.length === 0) {
document.getElementById('tab-content').innerHTML = '<div class="text-center py-8 text-gray-500">Нет данных</div>';
return;
}
let 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">Категория</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-right 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">Настройки</th>';
html += '</tr></thead><tbody class="divide-y">';
components.forEach((c, idx) => {
const price = c.current_price ? '$' + parseFloat(c.current_price).toLocaleString('en-US', {minimumFractionDigits: 2}) : '—';
const category = c.category ? c.category.code : '—';
const desc = c.lot && c.lot.lot_description ? c.lot.lot_description : '—';
const quoteCount = c.quote_count || 0;
const popularity = c.popularity_score ? c.popularity_score.toFixed(2) : '0.00';
const isHidden = c.is_hidden || quoteCount === 0;
const usedInMeta = c.used_in_meta && c.used_in_meta.length > 0;
// Determine status indicator (colored dot)
let dotColor, dotTitle;
if (usedInMeta) {
// Used as source for meta-articles - cyan
dotColor = 'bg-cyan-500';
dotTitle = 'Используется в мета: ' + c.used_in_meta.join(', ');
} else if (!isHidden) {
// Available in configurator - green
dotColor = 'bg-green-500';
dotTitle = 'Доступен в конфигураторе';
} else {
// Hidden and not used - gray
dotColor = 'bg-gray-400';
dotTitle = 'Скрыт из конфигуратора';
}
// Build settings summary
let settingsHtml = '';
if (isHidden) {
settingsHtml = '<span class="text-red-600 font-medium">СКРЫТ</span>';
} else {
let settings = [];
const method = c.price_method || 'median';
const hasManualPrice = c.manual_price && c.manual_price > 0;
const hasMeta = c.meta_prices && c.meta_prices.trim() !== '';
// Method indicator
if (hasManualPrice) {
settings.push('<span class="text-orange-600 font-medium">РУЧН</span>');
} else if (method === 'average') {
settings.push('Сред');
} else {
settings.push('Мед');
}
// Period (only if not manual price)
if (!hasManualPrice) {
const period = c.price_period_days !== undefined && c.price_period_days !== null ? c.price_period_days : 90;
if (period === 7) settings.push('1н');
else if (period === 30) settings.push('1м');
else if (period === 90) settings.push('3м');
else if (period === 365) settings.push('1г');
else if (period === 0) settings.push('все');
else settings.push(period + 'д');
}
// Coefficient
if (c.price_coefficient && c.price_coefficient !== 0) {
settings.push((c.price_coefficient > 0 ? '+' : '') + c.price_coefficient + '%');
}
// Meta article indicator
if (hasMeta) {
settings.push('<span class="text-purple-600 font-medium">МЕТА</span>');
}
settingsHtml = settings.join(' | ');
}
html += '<tr class="hover:bg-gray-50 cursor-pointer" onclick="openModal(' + idx + ')">';
html += '<td class="px-3 py-2 text-sm font-mono"><span class="inline-flex items-center gap-2"><span class="w-2.5 h-2.5 rounded-full ' + dotColor + ' flex-shrink-0" title="' + escapeHtml(dotTitle) + '"></span>' + escapeHtml(c.lot_name) + '</span></td>';
html += '<td class="px-3 py-2 text-sm">' + escapeHtml(category) + '</td>';
html += '<td class="px-3 py-2 text-sm text-gray-500 max-w-xs truncate">' + escapeHtml(desc) + '</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">' + quoteCount + '</td>';
html += '<td class="px-3 py-2 text-sm text-right font-medium">' + price + '</td>';
html += '<td class="px-3 py-2 text-sm"><span class="text-xs bg-gray-100 px-2 py-1 rounded">' + settingsHtml + '</span></td>';
html += '</tr>';
});
html += '</tbody></table></div>';
document.getElementById('tab-content').innerHTML = html;
}
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Modal functions
function openModal(idx) {
const c = componentsCache[idx];
if (!c) return;
document.getElementById('modal-lot-name').value = c.lot_name;
document.getElementById('modal-coefficient').value = c.price_coefficient || 0;
const hasManual = c.manual_price && c.manual_price > 0;
if (hasManual) {
document.getElementById('modal-method').value = 'manual';
document.getElementById('modal-manual-price').value = c.manual_price;
document.getElementById('manual-price-field').classList.remove('hidden');
} else {
document.getElementById('modal-method').value = c.price_method || 'median';
document.getElementById('modal-manual-price').value = '';
document.getElementById('manual-price-field').classList.add('hidden');
}
document.getElementById('modal-period').value = String(c.price_period_days !== undefined && c.price_period_days !== null ? c.price_period_days : 90);
// Load meta prices settings
const hasMeta = c.meta_prices && c.meta_prices.trim() !== '';
document.getElementById('modal-meta-enabled').checked = hasMeta;
document.getElementById('modal-meta-prices').value = c.meta_prices || '';
if (hasMeta) {
document.getElementById('meta-price-fields').classList.remove('hidden');
} else {
document.getElementById('meta-price-fields').classList.add('hidden');
}
// Load hidden flag
const quoteCount = c.quote_count || 0;
const hiddenCheckbox = document.getElementById('modal-hidden');
if (quoteCount === 0) {
// Если нет котировок - чекбокс установлен и заблокирован
hiddenCheckbox.checked = true;
hiddenCheckbox.disabled = true;
} else {
hiddenCheckbox.checked = c.is_hidden || false;
hiddenCheckbox.disabled = false;
}
// Reset price displays while loading
document.getElementById('modal-last-price').textContent = '...';
document.getElementById('modal-median-all').textContent = '...';
document.getElementById('modal-current-price').textContent = '...';
document.getElementById('modal-new-price').textContent = '...';
document.getElementById('modal-quote-count').textContent = '...';
document.getElementById('price-modal').classList.remove('hidden');
document.getElementById('price-modal').classList.add('flex');
// Fetch price preview
fetchPreview();
}
function onMethodChange() {
const method = document.getElementById('modal-method').value;
const manualField = document.getElementById('manual-price-field');
if (method === 'manual') {
manualField.classList.remove('hidden');
// При выборе "Установить цену вручную" снимаем галочку "Мета артикул"
document.getElementById('modal-meta-enabled').checked = false;
document.getElementById('meta-price-fields').classList.add('hidden');
} else {
manualField.classList.add('hidden');
}
fetchPreview();
}
async function fetchPreview() {
const lotName = document.getElementById('modal-lot-name').value;
const method = document.getElementById('modal-method').value;
const periodDays = parseInt(document.getElementById('modal-period').value) || 0;
const coefficient = parseFloat(document.getElementById('modal-coefficient').value) || 0;
const metaEnabled = document.getElementById('modal-meta-enabled').checked;
let metaPrices = '';
let metaMethod = '';
let metaPeriod = 0;
if (metaEnabled) {
metaPrices = document.getElementById('modal-meta-prices').value.trim();
metaMethod = method;
metaPeriod = periodDays;
}
try {
const resp = await fetch('/api/admin/pricing/preview', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
lot_name: lotName,
method: method,
period_days: periodDays,
coefficient: coefficient,
meta_enabled: metaEnabled,
meta_prices: metaPrices,
meta_method: metaMethod,
meta_period: metaPeriod
})
});
if (resp.ok) {
const data = await resp.json();
// Update last price with date
if (data.last_price) {
let lastPriceText = '$' + parseFloat(data.last_price).toFixed(2);
if (data.last_price_date) {
const date = new Date(data.last_price_date);
lastPriceText += ' (' + date.toLocaleDateString('ru-RU') + ')';
}
document.getElementById('modal-last-price').textContent = lastPriceText;
} else {
document.getElementById('modal-last-price').textContent = '—';
}
// Update median all time
document.getElementById('modal-median-all').textContent =
data.median_all_time ? '$' + parseFloat(data.median_all_time).toFixed(2) : '—';
// Update current price
document.getElementById('modal-current-price').textContent =
data.current_price ? '$' + parseFloat(data.current_price).toFixed(2) : '—';
// Update new calculated price
document.getElementById('modal-new-price').textContent =
data.new_price ? '$' + parseFloat(data.new_price).toFixed(2) : '—';
// Update quote count with new format "N (всего: M)"
let quoteCountText = '';
if (data.quote_count_period !== undefined && data.quote_count_total !== undefined) {
if (data.quote_count_period === data.quote_count_total) {
// If period count equals total count, just show the total
quoteCountText = data.quote_count_total;
} else {
// Show both counts in format "N (всего: M)"
quoteCountText = data.quote_count_period + ' (всего: ' + data.quote_count_total + ')';
}
} else {
// Fallback for older API responses
quoteCountText = data.quote_count || 0;
}
document.getElementById('modal-quote-count').textContent = quoteCountText;
}
} catch(e) {
console.error('Preview fetch error:', e);
document.getElementById('modal-last-price').textContent = '—';
document.getElementById('modal-median-all').textContent = '—';
document.getElementById('modal-current-price').textContent = '—';
document.getElementById('modal-new-price').textContent = '—';
}
}
function closeModal() {
document.getElementById('price-modal').classList.add('hidden');
document.getElementById('price-modal').classList.remove('flex');
}
function toggleMetaPrice() {
const enabled = document.getElementById('modal-meta-enabled').checked;
const fields = document.getElementById('meta-price-fields');
fields.classList.toggle('hidden', !enabled);
if (enabled) {
// When enabling meta price, reset method to median if it was manual
const method = document.getElementById('modal-method').value;
if (method === 'manual') {
document.getElementById('modal-method').value = 'median';
document.getElementById('manual-price-field').classList.add('hidden');
document.getElementById('modal-manual-price').value = '';
}
// Auto-fill with wildcard pattern
const lotName = document.getElementById('modal-lot-name').value;
if (lotName) {
autoFillMetaPrices(lotName);
}
}
fetchPreview();
}
// Debounce helper for preview updates
let previewTimeout = null;
function debounceFetchPreview() {
clearTimeout(previewTimeout);
previewTimeout = setTimeout(fetchPreview, 300);
}
async function savePrice() {
const lotName = document.getElementById('modal-lot-name').value;
const method = document.getElementById('modal-method').value;
const periodDaysStr = document.getElementById('modal-period').value;
const periodDays = periodDaysStr !== '' ? parseInt(periodDaysStr) : 90;
const coefficient = parseFloat(document.getElementById('modal-coefficient').value) || 0;
const manualEnabled = method === 'manual';
const manualPrice = manualEnabled ? parseFloat(document.getElementById('modal-manual-price').value) : null;
const metaEnabled = document.getElementById('modal-meta-enabled').checked;
const hiddenCheckbox = document.getElementById('modal-hidden');
// Если чекбокс заблокирован (нет котировок), всегда true
const isHidden = hiddenCheckbox.disabled ? true : hiddenCheckbox.checked;
let metaPrices = '';
let metaMethod = '';
let metaPeriod = 0;
if (metaEnabled) {
metaPrices = document.getElementById('modal-meta-prices').value.trim();
metaMethod = manualEnabled ? 'median' : method;
metaPeriod = periodDays;
}
const body = {
lot_name: lotName,
method: manualEnabled ? 'median' : method,
period_days: periodDays,
coefficient: coefficient,
clear_manual: !manualEnabled,
meta_enabled: metaEnabled,
meta_prices: metaPrices,
meta_method: metaMethod,
meta_period: metaPeriod,
is_hidden: isHidden
};
if (manualEnabled && manualPrice > 0) {
body.manual_price = manualPrice;
}
try {
const resp = await fetch('/api/admin/pricing/update', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(body)
});
if (resp.ok) {
closeModal();
loadData();
} else {
const data = await resp.json();
alert('Ошибка: ' + (data.error || 'Неизвестная ошибка'));
}
} catch(e) {
alert('Ошибка соединения');
}
}
// Function to process meta prices and handle regex patterns
function processMetaPrices(metaPrices, originalLotName) {
if (!metaPrices) return [];
// Split by comma and clean up
let lots = metaPrices.split(',').map(lot => lot.trim()).filter(lot => lot.length > 0);
// Handle wildcard patterns (ending with *)
const processedLots = [];
const originalPrefix = originalLotName.split('_')[0] + '_'; // Get first part like "CPU_" from "CPU_AMD_9654"
lots.forEach(lot => {
if (lot.endsWith('*')) {
// Wildcard pattern - find all components that start with the prefix
const prefix = lot.slice(0, -1); // Remove the *
// In real implementation, this would be handled by backend
// For now, we'll just add the prefix as is to indicate it's a pattern
processedLots.push(prefix + '*');
} else {
// Regular component name
processedLots.push(lot);
}
});
// Remove duplicates and original lot name
const uniqueLots = [...new Set(processedLots)];
return uniqueLots.filter(lot => lot !== originalLotName);
}
function recalculateAll() {
const btn = document.getElementById('btn-recalc');
const progressContainer = document.getElementById('progress-container');
const progressBar = document.getElementById('progress-bar');
const progressText = document.getElementById('progress-text');
const progressPercent = document.getElementById('progress-percent');
const progressStats = document.getElementById('progress-stats');
// Show progress bar IMMEDIATELY
btn.disabled = true;
btn.textContent = 'Обновление...';
progressContainer.style.display = 'block';
progressBar.style.width = '0%';
progressBar.className = 'bg-blue-600 h-4 rounded-full transition-all duration-300';
progressText.textContent = 'Запуск обновления...';
progressPercent.textContent = '0%';
progressStats.textContent = 'Подготовка...';
// Use fetch with streaming for SSE
fetch('/api/admin/pricing/recalculate-all', {
method: 'POST'
}).then(response => {
const reader = response.body.getReader();
const decoder = new TextDecoder();
function read() {
reader.read().then(({done, value}) => {
if (done) {
btn.disabled = false;
btn.textContent = 'Обновить цены';
progressText.textContent = 'Готово!';
progressBar.className = 'bg-green-600 h-4 rounded-full';
setTimeout(() => {
progressContainer.style.display = 'none';
if (currentTab === 'component-settings') {
loadData();
}
}, 2000);
return;
}
const text = decoder.decode(value);
const lines = text.split('\n');
lines.forEach(line => {
if (line.startsWith('data:')) {
try {
const data = JSON.parse(line.substring(5).trim());
const percent = data.total > 0 ? Math.round((data.current / data.total) * 100) : 0;
progressBar.style.width = percent + '%';
progressPercent.textContent = percent + '%';
if (data.status === 'completed') {
progressText.textContent = 'Обновление завершено!';
progressBar.className = 'bg-green-600 h-4 rounded-full';
} else {
progressText.textContent = data.lot_name ? 'Обработка: ' + data.lot_name : 'Обработка компонентов...';
}
progressStats.textContent = 'Обновлено: ' + (data.updated || 0) + ' | Без изменений: ' + (data.unchanged || 0) + ' | Ручные: ' + (data.manual || 0) + ' | Нет данных: ' + (data.skipped || 0) + ' | Ошибок: ' + (data.errors || 0);
} catch(e) {
console.log('Parse error:', e, line);
}
}
});
read();
});
}
read();
}).catch(e => {
console.error('Fetch error:', e);
alert('Ошибка соединения');
btn.disabled = false;
btn.textContent = 'Обновить цены';
progressContainer.style.display = 'none';
});
}
// Close modal on click outside
document.getElementById('price-modal').addEventListener('click', function(e) {
if (e.target === this) {
closeModal();
}
});
function changeSort() {
sortField = document.getElementById('sort-field').value;
currentPage = 1;
loadData();
}
function toggleSortDir() {
sortDir = sortDir === 'asc' ? 'desc' : 'asc';
document.getElementById('sort-dir-btn').textContent = sortDir === 'asc' ? '↑' : '↓';
currentPage = 1;
loadData();
}
// Render all configurations for admin view
function renderAllConfigs(configs) {
if (configs.length === 0) {
document.getElementById('tab-content').innerHTML = '<div class="text-center py-8 text-gray-500">Нет конфигураций</div>';
return;
}
let 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">Название</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-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 += '</tr></thead><tbody class="divide-y">';
configs.forEach(c => {
const date = new Date(c.created_at).toLocaleDateString('ru-RU');
const total = c.total_price ? '$' + c.total_price.toLocaleString('en-US', {minimumFractionDigits: 2}) : '—';
const serverCount = c.server_count ? c.server_count : 1;
const username = c.owner_username || (c.user ? c.user.username : '—');
html += '<tr class="hover:bg-gray-50">';
html += '<td class="px-3 py-2 text-sm text-gray-500">' + date + '</td>';
html += '<td class="px-3 py-2 text-sm font-medium">' + escapeHtml(c.name) + '</td>';
html += '<td class="px-3 py-2 text-sm text-gray-500">' + username + '</td>';
html += '<td class="px-3 py-2 text-sm text-gray-500">' + serverCount + '</td>';
html += '<td class="px-3 py-2 text-sm text-right">' + total + '</td>';
html += '<td class="px-3 py-2 text-sm text-right space-x-2">';
html += '<button onclick="openCloneModal(\'' + c.uuid + '\', \'' + escapeHtml(c.name).replace(/'/g, "\\'") + '\')" class="text-green-600 hover:text-green-800" title="Копировать">';
html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">';
html += '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path>';
html += '</svg>';
html += '</button>';
html += '<button onclick="openRenameModal(\'' + c.uuid + '\', \'' + escapeHtml(c.name).replace(/'/g, "\\'") + '\')" class="text-blue-600 hover:text-blue-800" title="Переименовать">';
html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">';
html += '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path>';
html += '</svg>';
html += '</button>';
html += '<button onclick="deleteConfig(\'' + c.uuid + '\')" class="text-red-600 hover:text-red-800" title="Удалить">';
html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">';
html += '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>';
html += '</svg>';
html += '</button>';
html += '</td></tr>';
});
html += '</tbody></table></div>';
document.getElementById('tab-content').innerHTML = html;
}
async function importStockFile() {
const input = document.getElementById('stock-file-input');
if (!input.files || input.files.length === 0) {
showToast('Выберите файл .mxl или .xlsx', 'error');
return;
}
const file = input.files[0];
const fd = new FormData();
fd.append('file', file);
const box = document.getElementById('stock-import-progress');
const statusEl = document.getElementById('stock-import-status');
const percentEl = document.getElementById('stock-import-percent');
const barEl = document.getElementById('stock-import-bar');
const statsEl = document.getElementById('stock-import-stats');
const suggestionsBox = document.getElementById('stock-import-suggestions');
box.classList.remove('hidden');
suggestionsBox.classList.add('hidden');
statusEl.textContent = 'Запуск импорта...';
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', {
method: 'POST',
body: fd
});
if (!resp.ok) {
let data = {};
try { data = await resp.json(); } catch (_) {}
throw new Error(data.error || 'Ошибка запуска импорта');
}
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let sseBuffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
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);
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 || 'Обработка';
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 Promise.allSettled([
loadPricelists(1, 'warehouse'),
loadStockMappings(stockMappingsPage),
loadStockIgnoreRules(stockIgnoreRulesPage),
]);
statusEl.textContent = 'Импорт и обновление списков завершены';
}
}
}
} catch (e) {
showToast('Ошибка импорта: ' + e.message, 'error');
}
}
function formatSuggestionReason(reason) {
if (reason === 'conflict') return 'Конфликт';
return 'Нет сопоставления с LOT';
}
function renderStockImportSuggestions(items) {
const box = document.getElementById('stock-import-suggestions');
const body = document.getElementById('stock-import-suggestions-body');
if (!box || !body) return;
if (!items || items.length === 0) {
box.classList.add('hidden');
body.innerHTML = '';
return;
}
body.innerHTML = items.map(item => `
<tr>
<td class="px-3 py-2 w-1/4 text-sm font-mono">
<input data-role="suggestion-lot" data-partnumber="${escapeHtml(item.partnumber || '')}" list="stock-lot-options" autocomplete="off" type="text" class="px-2 py-1 border rounded w-full font-mono" placeholder="Выберите LOT">
</td>
<td class="px-3 py-2 w-1/4 text-sm font-mono">${escapeHtml(item.partnumber || '')}</td>
<td class="px-3 py-2 text-sm text-gray-700">${escapeHtml(item.description || '—')}</td>
<td class="px-3 py-2 text-sm text-gray-700">${escapeHtml(formatSuggestionReason(item.reason || 'unmapped'))}</td>
<td class="px-3 py-2 text-right">
<div class="flex justify-end items-center gap-3">
<button data-partnumber="${escapeHtml(item.partnumber || '')}" data-description="${encodeURIComponent(item.description || '')}" onclick="addSuggestionMapping(this)" class="text-blue-600 hover:text-blue-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="M5 13l4 4L19 7"></path>
</svg>
</button>
<button data-partnumber="${escapeHtml(item.partnumber || '')}" onclick="ignoreSuggestion(this)" class="text-amber-600 hover:text-amber-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="M18.364 5.636l-12.728 12.728M5.636 5.636l12.728 12.728"></path>
</svg>
</button>
</div>
</td>
</tr>
`).join('');
box.classList.remove('hidden');
}
async function addSuggestionMapping(button) {
const partnumber = (button?.dataset?.partnumber || '').trim();
if (!partnumber) return;
const description = decodeURIComponent(button?.dataset?.description || '');
const input = document.querySelector(`input[data-role="suggestion-lot"][data-partnumber="${CSS.escape(partnumber)}"]`);
const lotName = (input?.value || '').trim();
try {
const resp = await fetch('/api/admin/pricing/stock/mappings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ partnumber: partnumber, lot_name: lotName, description: description })
});
const data = await resp.json();
if (!resp.ok) throw new Error(data.error || 'Ошибка сохранения');
stockImportSuggestions = stockImportSuggestions.filter(item => (item.partnumber || '').trim().toLowerCase() !== partnumber.toLowerCase());
renderStockImportSuggestions(stockImportSuggestions);
await loadStockMappings(1);
showToast('Сопоставление добавлено', 'success');
} catch (e) {
showToast('Ошибка: ' + e.message, 'error');
}
}
async function ignoreSuggestion(button) {
const partnumber = (button?.dataset?.partnumber || '').trim();
if (!partnumber) return;
try {
const resp = await fetch('/api/admin/pricing/stock/ignore-rules', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ target: 'partnumber', match_type: 'exact', pattern: partnumber })
});
const data = await resp.json();
if (!resp.ok) throw new Error(data.error || 'Ошибка добавления в игнорирование');
stockImportSuggestions = stockImportSuggestions.filter(item => (item.partnumber || '').trim().toLowerCase() !== partnumber.toLowerCase());
renderStockImportSuggestions(stockImportSuggestions);
await loadStockIgnoreRules(1);
showToast('Добавлено в игнорирование', 'success');
} catch (e) {
showToast('Ошибка: ' + e.message, 'error');
}
}
async function addAllSuggestions() {
const btn = document.getElementById('btn-add-all-suggestions');
if (btn?.disabled) return;
if (btn) btn.disabled = true;
try {
const descriptionByPartnumber = {};
for (const s of stockImportSuggestions) {
const pn = (s.partnumber || '').trim().toLowerCase();
if (!pn) continue;
descriptionByPartnumber[pn] = s.description || '';
}
const inputs = Array.from(document.querySelectorAll('input[data-role="suggestion-lot"]'));
const tasks = inputs
.map(input => ({
partnumber: (input.dataset.partnumber || '').trim(),
lot: (input.value || '').trim(),
description: descriptionByPartnumber[((input.dataset.partnumber || '').trim().toLowerCase())] || ''
}))
.filter(x => x.partnumber);
if (tasks.length === 0) {
showToast('Нет строк для добавления', 'error');
return;
}
let ok = 0;
for (const t of tasks) {
const resp = await fetch('/api/admin/pricing/stock/mappings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ partnumber: t.partnumber, lot_name: t.lot, description: t.description })
});
if (resp.ok) ok++;
}
stockImportSuggestions = stockImportSuggestions.filter(item => !tasks.some(t => t.partnumber.toLowerCase() === (item.partnumber || '').toLowerCase()));
renderStockImportSuggestions(stockImportSuggestions);
await loadStockMappings(1);
showToast(`Добавлено: ${ok}`, 'success');
} finally {
if (btn) btn.disabled = false;
}
}
async function ignoreAllSuggestions() {
if (!stockImportSuggestions.length) return;
const btn = document.getElementById('btn-ignore-all-suggestions');
if (btn?.disabled) return;
if (btn) btn.disabled = true;
try {
let ok = 0;
for (const s of stockImportSuggestions) {
const partnumber = (s.partnumber || '').trim();
if (!partnumber) continue;
const resp = await fetch('/api/admin/pricing/stock/ignore-rules', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ target: 'partnumber', match_type: 'exact', pattern: partnumber })
});
if (resp.ok) ok++;
}
stockImportSuggestions = [];
renderStockImportSuggestions(stockImportSuggestions);
await loadStockIgnoreRules(1);
showToast(`Добавлено в игнорирование: ${ok}`, 'success');
} finally {
if (btn) btn.disabled = false;
}
}
async function loadStockMappings(page = 1) {
stockMappingsPage = page;
const body = document.getElementById('stock-mappings-body');
const pagination = document.getElementById('stock-mappings-pagination');
if (!body || !pagination) return;
try {
const query = encodeURIComponent(stockMappingsSearch);
const resp = await fetch(`/api/admin/pricing/stock/mappings?page=${page}&per_page=20&search=${query}`);
const data = await resp.json();
if (!resp.ok) throw new Error(data.error || 'Ошибка загрузки');
const items = data.items || [];
if (items.length === 0) {
body.innerHTML = '<tr><td colspan="4" class="px-4 py-3 text-sm text-gray-500">Нет сопоставлений</td></tr>';
} else {
body.innerHTML = items.map(item => `
<tr>
<td class="px-4 py-2 w-1/4 text-sm font-mono">
<input data-role="lot" type="text" list="stock-lot-options" autocomplete="off" placeholder="Выберите LOT" value="${escapeHtml(item.lot_name || item.LotName || '')}" class="px-2 py-1 border rounded w-full font-mono">
</td>
<td class="px-4 py-2 w-1/4 text-sm font-mono">${escapeHtml(item.partnumber || item.Partnumber || '—')}</td>
<td class="px-4 py-2 text-sm text-gray-600">${escapeHtml(item.description || item.Description || '—')}</td>
<td class="px-4 py-2">
<div class="flex justify-end items-center gap-3">
<button data-partnumber="${escapeHtml(item.partnumber || item.Partnumber || '')}" onclick="saveInlineStockMapping(this)" class="text-blue-600 hover:text-blue-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="M5 13l4 4L19 7"></path>
</svg>
</button>
<button data-partnumber="${escapeHtml(item.partnumber || item.Partnumber || '')}" onclick="event.stopPropagation(); ignoreStockMapping(this.dataset.partnumber)" class="text-amber-600 hover:text-amber-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="M18.364 5.636l-12.728 12.728M5.636 5.636l12.728 12.728"></path>
</svg>
</button>
<button data-partnumber="${escapeHtml(item.partnumber || item.Partnumber || '')}" onclick="event.stopPropagation(); deleteStockMapping(this.dataset.partnumber)" 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>
</div>
</td>
</tr>
`).join('');
}
const totalPages = Math.ceil((data.total || 0) / (data.per_page || 20));
if (totalPages <= 1) {
pagination.innerHTML = '';
} else {
let html = '';
for (let i = 1; i <= totalPages; i++) {
const cls = i === page ? 'bg-blue-600 text-white' : 'bg-white text-gray-700 hover:bg-gray-50';
html += `<button onclick="loadStockMappings(${i})" class="px-3 py-1 rounded border ${cls}">${i}</button>`;
}
pagination.innerHTML = html;
}
} catch (e) {
body.innerHTML = `<tr><td colspan="4" class="px-4 py-3 text-sm text-red-600">${escapeHtml(e.message)}</td></tr>`;
pagination.innerHTML = '';
}
}
function applyStockMappingsSearch() {
stockMappingsSearch = (document.getElementById('stock-mappings-search')?.value || '').trim();
loadStockMappings(1);
}
function onStockMappingsSearchInput() {
clearTimeout(stockMappingsSearchTimer);
stockMappingsSearchTimer = setTimeout(applyStockMappingsSearch, 250);
}
async function saveInlineStockMapping(button) {
const partnumber = (button?.dataset?.partnumber || '').trim();
if (!partnumber) return;
const row = button.closest('tr');
const lotName = (row?.querySelector('input[data-role="lot"]')?.value || '').trim();
if (!lotName) {
showToast('LOT не может быть пустым', 'error');
return;
}
try {
const resp = await fetch('/api/admin/pricing/stock/mappings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ partnumber: partnumber, lot_name: lotName })
});
const data = await resp.json();
if (!resp.ok) throw new Error(data.error || 'Ошибка сохранения');
showToast('Сопоставление сохранено', 'success');
await loadStockMappings(stockMappingsPage);
} catch (e) {
showToast('Ошибка: ' + e.message, 'error');
}
}
async function loadStockLotOptions() {
const datalist = document.getElementById('stock-lot-options');
if (!datalist) return;
try {
const resp = await fetch('/api/admin/pricing/lots?per_page=5000');
const data = await resp.json();
if (!resp.ok) throw new Error(data.error || 'Ошибка загрузки LOT');
const items = data.items || [];
datalist.innerHTML = items.map(lot => `<option value="${escapeHtml(lot)}"></option>`).join('');
} catch (_) {
datalist.innerHTML = '';
}
}
async function loadStockIgnoreRules(page = 1) {
stockIgnoreRulesPage = page;
const body = document.getElementById('stock-ignore-rules-body');
const pagination = document.getElementById('stock-ignore-rules-pagination');
if (!body || !pagination) return;
try {
const resp = await fetch(`/api/admin/pricing/stock/ignore-rules?page=${page}&per_page=20`);
const data = await resp.json();
if (!resp.ok) throw new Error(data.error || 'Ошибка загрузки');
const items = data.items || [];
if (items.length === 0) {
body.innerHTML = '<tr><td colspan="4" class="px-4 py-3 text-sm text-gray-500">Нет правил</td></tr>';
} else {
const matchLabel = { exact: 'Равно', prefix: 'Начинается с', suffix: 'Заканчивается на' };
body.innerHTML = items.map(item => `
<tr>
<td class="px-4 py-2 text-sm">${escapeHtml(item.target)}</td>
<td class="px-4 py-2 text-sm">${escapeHtml(matchLabel[item.match_type] || item.match_type)}</td>
<td class="px-4 py-2 text-sm font-mono">${escapeHtml(item.pattern)}</td>
<td class="px-4 py-2 text-right">
<button onclick="deleteStockIgnoreRule(${item.id})" class="text-red-600 hover:text-red-800 text-sm">Удалить</button>
</td>
</tr>
`).join('');
}
const totalPages = Math.ceil((data.total || 0) / (data.per_page || 20));
if (totalPages <= 1) {
pagination.innerHTML = '';
} else {
let html = '';
for (let i = 1; i <= totalPages; i++) {
const cls = i === page ? 'bg-blue-600 text-white' : 'bg-white text-gray-700 hover:bg-gray-50';
html += `<button onclick="loadStockIgnoreRules(${i})" class="px-3 py-1 rounded border ${cls}">${i}</button>`;
}
pagination.innerHTML = html;
}
} catch (e) {
body.innerHTML = `<tr><td colspan="4" class="px-4 py-3 text-sm text-red-600">${escapeHtml(e.message)}</td></tr>`;
pagination.innerHTML = '';
}
}
async function saveStockIgnoreRule() {
const target = document.getElementById('ignore-target').value;
const matchType = document.getElementById('ignore-match-type').value;
const pattern = document.getElementById('ignore-pattern').value.trim();
if (!pattern) {
showToast('Заполните шаблон', 'error');
return;
}
try {
const resp = await fetch('/api/admin/pricing/stock/ignore-rules', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ target: target, match_type: matchType, pattern: pattern })
});
const data = await resp.json();
if (!resp.ok) throw new Error(data.error || 'Ошибка сохранения');
document.getElementById('ignore-pattern').value = '';
showToast('Правило добавлено', 'success');
await loadStockIgnoreRules(1);
} catch (e) {
showToast('Ошибка: ' + e.message, 'error');
}
}
async function deleteStockIgnoreRule(id) {
if (!confirm('Удалить правило игнорирования?')) return;
try {
const resp = await fetch(`/api/admin/pricing/stock/ignore-rules/${id}`, {
method: 'DELETE'
});
const data = await resp.json();
if (!resp.ok) throw new Error(data.error || 'Ошибка удаления');
showToast('Правило удалено', 'success');
await loadStockIgnoreRules(stockIgnoreRulesPage);
} catch (e) {
showToast('Ошибка: ' + e.message, 'error');
}
}
async function deleteStockMapping(partnumber) {
if (!confirm('Удалить сопоставление для ' + partnumber + '?')) return;
try {
const resp = await fetch(`/api/admin/pricing/stock/mappings/${encodeURIComponent(partnumber)}`, {
method: 'DELETE'
});
const data = await resp.json();
if (!resp.ok) throw new Error(data.error || 'Ошибка удаления');
showToast('Сопоставление удалено', 'success');
await loadStockMappings(stockMappingsPage);
} catch (e) {
showToast('Ошибка: ' + e.message, 'error');
}
}
async function ignoreStockMapping(partnumber) {
const pn = (partnumber || '').trim();
if (!pn) return;
if (!confirm('Добавить партномер в игнорирование и удалить из сопоставлений?')) return;
try {
const addResp = await fetch('/api/admin/pricing/stock/ignore-rules', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ target: 'partnumber', match_type: 'exact', pattern: pn })
});
const addData = await addResp.json();
if (!addResp.ok) throw new Error(addData.error || 'Ошибка добавления в игнорирование');
const delResp = await fetch(`/api/admin/pricing/stock/mappings/${encodeURIComponent(pn)}`, {
method: 'DELETE'
});
const delData = await delResp.json();
if (!delResp.ok) throw new Error(delData.error || 'Ошибка удаления сопоставления');
showToast('Партномер добавлен в игнорирование', 'success');
await loadStockMappings(stockMappingsPage);
await loadStockIgnoreRules(1);
} catch (e) {
showToast('Ошибка: ' + e.message, 'error');
}
}
document.addEventListener('DOMContentLoaded', async () => {
await checkPricelistWritePermission();
// Check URL params for initial tab
const urlParams = new URLSearchParams(window.location.search);
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');
if (stockMappingsSearchEl) {
stockMappingsSearchEl.addEventListener('input', onStockMappingsSearchInput);
}
// Add event listeners for preview updates
document.getElementById('modal-period').addEventListener('change', fetchPreview);
document.getElementById('modal-coefficient').addEventListener('input', debounceFetchPreview);
document.getElementById('modal-manual-price').addEventListener('input', debounceFetchPreview);
document.getElementById('modal-meta-prices').addEventListener('input', debounceFetchPreview);
});
async function checkPricelistWritePermission() {
try {
const resp = await fetch('/api/pricelists/can-write');
const data = await resp.json();
pricelistsCanWrite = data.can_write;
if (pricelistsCanWrite) {
updatePricelistsCreateButton();
document.getElementById('btn-sync-status').classList.remove('hidden');
if (currentTab === 'sync-status') {
await loadUsersSyncStatus();
startSyncUsersStatusRefresh();
}
} else {
updatePricelistsCreateButton();
document.getElementById('btn-sync-status').classList.add('hidden');
stopSyncUsersStatusRefresh();
if (currentTab === 'sync-status') {
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();
}
}
function formatRelativeTime(lastSyncAt) {
const timestamp = new Date(lastSyncAt);
if (Number.isNaN(timestamp.getTime())) return '—';
const diffMinutes = Math.max(1, Math.floor((Date.now() - timestamp.getTime()) / 60000));
if (diffMinutes < 60) return `${diffMinutes} мин назад`;
const diffHours = Math.floor(diffMinutes / 60);
if (diffHours < 24) return `${diffHours} ч назад`;
const diffDays = Math.floor(diffHours / 24);
if (diffDays < 7) return `${diffDays} дн назад`;
const diffWeeks = Math.floor(diffDays / 7);
if (diffWeeks < 5) return `${diffWeeks} нед назад`;
const diffMonths = Math.floor(diffDays / 30);
if (diffMonths < 12) return `${diffMonths} мес назад`;
const diffYears = Math.floor(diffDays / 365);
return `${diffYears} г назад`;
}
async function loadUsersSyncStatus() {
if (!pricelistsCanWrite) return;
const body = document.getElementById('sync-users-status-body');
if (!body) return;
try {
const resp = await fetch('/api/sync/users-status');
const data = await resp.json();
if (!resp.ok) {
throw new Error(data.error || 'Ошибка загрузки');
}
const users = data.users || [];
if (users.length === 0) {
body.innerHTML = `
<tr>
<td colspan="3" class="px-6 py-4 text-sm text-gray-500">
Нет данных о синхронизации пользователей
</td>
</tr>
`;
return;
}
body.innerHTML = users.map(u => {
const statusClass = u.is_online ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-700';
const statusText = u.is_online ? 'онлайн' : formatRelativeTime(u.last_sync_at);
return `
<tr>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-800">${escapeHtml(u.username || '—')}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-600">${escapeHtml(u.app_version || '—')}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm">
<span class="px-2 py-1 text-xs rounded-full ${statusClass}">${statusText}</span>
</td>
</tr>
`;
}).join('');
} catch (e) {
body.innerHTML = `
<tr>
<td colspan="3" class="px-6 py-4 text-sm text-red-600">
Ошибка загрузки статусов синхронизации: ${escapeHtml(e.message || String(e))}
</td>
</tr>
`;
}
}
async function loadPricelists(page = 1, source = currentPricelistSource) {
pricelistsPage = page;
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="8" class="px-6 py-4 text-center text-red-500">
Ошибка загрузки: ${e.message}
</td>
</tr>
`;
// Hide pagination when there's an error
document.getElementById('pricelists-pagination').innerHTML = '';
}
}
function renderPricelists(pricelists) {
if (pricelists.length === 0) {
document.getElementById('pricelists-body').innerHTML = `
<tr>
<td colspan="8" class="px-6 py-4 text-center text-gray-500">
Прайслисты не найдены. ${pricelistsCanWrite ? 'Создайте первый прайслист.' : ''}
</td>
</tr>
`;
return;
}
const html = pricelists.map(pl => {
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 = '';
if (pricelistsCanWrite) {
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" 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">
${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>
<td class="px-6 py-4 whitespace-nowrap text-center text-sm">${pl.usage_count}</td>
<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">
<div class="inline-flex items-center justify-end gap-3">${actions}</div>
</td>
</tr>
`;
}).join('');
document.getElementById('pricelists-body').innerHTML = html;
}
async function togglePricelistActive(id, isActive) {
// Check if online before toggling
const isOnline = await checkOnlineStatus();
if (!isOnline) {
showToast('Изменение статуса прайслиста доступно только в онлайн режиме', 'error');
return;
}
try {
const resp = await fetch(`/api/pricelists/${id}/active`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ is_active: isActive })
});
if (!resp.ok) {
const data = await resp.json();
throw new Error(data.error || 'Failed to update status');
}
showToast('Статус прайслиста обновлен', 'success');
loadPricelists(pricelistsPage, currentPricelistSource);
} catch (e) {
showToast('Ошибка: ' + e.message, 'error');
}
}
function renderPricelistsPagination(total, page, perPage) {
const totalPages = Math.ceil(total / perPage);
if (totalPages <= 1) {
document.getElementById('pricelists-pagination').innerHTML = '';
return;
}
let html = '';
for (let i = 1; i <= totalPages; i++) {
const activeClass = i === page ? 'bg-blue-600 text-white' : 'bg-white text-gray-700 hover:bg-gray-50';
html += `<button onclick="loadPricelists(${i}, '${currentPricelistSource}')" class="px-3 py-1 rounded border ${activeClass}">${i}</button>`;
}
document.getElementById('pricelists-pagination').innerHTML = html;
}
async function loadPricelistsDbUsername() {
if (cachedDbUsername) {
document.getElementById('pricelists-db-username').textContent = cachedDbUsername;
return;
}
try {
const resp = await fetch('/api/current-user');
const data = await resp.json();
cachedDbUsername = data.username || 'неизвестно';
document.getElementById('pricelists-db-username').textContent = cachedDbUsername;
} catch (e) {
document.getElementById('pricelists-db-username').textContent = 'неизвестно';
}
}
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();
loadPricelistsDbUsername();
}
function closePricelistsCreateModal() {
document.getElementById('pricelists-create-modal').classList.add('hidden');
document.getElementById('pricelists-create-modal').classList.remove('flex');
}
async function checkOnlineStatus() {
try {
const resp = await fetch('/api/db-status');
const data = await resp.json();
return data.connected === true;
} catch(e) {
return false;
}
}
async function createPricelist() {
if (!canCreatePricelistForCurrentTab()) {
throw new Error('Выберите вкладку Estimate, Склад или Конкуренты');
}
// Check if online before creating
const isOnline = await checkOnlineStatus();
if (!isOnline) {
throw new Error('Создание прайслистов доступно только в онлайн режиме');
}
const progressBox = document.getElementById('pricelist-create-progress');
const progressBar = document.getElementById('pricelist-create-progress-bar');
const progressText = document.getElementById('pricelist-create-progress-text');
const progressPercent = document.getElementById('pricelist-create-progress-percent');
const progressStats = document.getElementById('pricelist-create-progress-stats');
progressBox.classList.remove('hidden');
progressBar.style.width = '0%';
progressText.textContent = 'Запуск создания прайслиста...';
progressPercent.textContent = '0%';
progressStats.textContent = '';
const resp = await fetch('/api/pricelists/create-with-progress', {
method: 'POST'
,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ source: currentPricelistSource })
});
if (!resp.ok) {
let data = {};
try { data = await resp.json(); } catch (_) {}
throw new Error(data.error || 'Failed to create pricelist');
}
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let completedPricelist = null;
while (true) {
const { done, value } = await reader.read();
if (done) break;
const text = decoder.decode(value);
const lines = text.split('\n');
for (const line of lines) {
if (!line.startsWith('data:')) continue;
let data;
try {
data = JSON.parse(line.slice(5).trim());
} catch (_) {
continue;
}
const current = Number(data.current || 0);
const total = Number(data.total || 0);
const percent = total > 0 ? Math.round((current / total) * 100) : 0;
progressBar.style.width = percent + '%';
progressPercent.textContent = percent + '%';
if (data.lot_name) {
progressText.textContent = (data.message || 'Обработка') + ': ' + data.lot_name;
} else {
progressText.textContent = data.message || 'Обработка...';
}
if (data.updated !== undefined || data.errors !== undefined) {
progressStats.textContent = 'Обновлено: ' + (data.updated || 0) + ' | Ошибок: ' + (data.errors || 0);
}
if (data.status === 'error') {
throw new Error(data.message || 'Ошибка создания прайслиста');
}
if (data.status === 'completed' && data.pricelist) {
completedPricelist = data.pricelist;
}
}
}
if (!completedPricelist) {
throw new Error('Создание прервано: не получен результат');
}
return completedPricelist;
}
function resetPricelistCreateProgress() {
const progressBox = document.getElementById('pricelist-create-progress');
const progressBar = document.getElementById('pricelist-create-progress-bar');
const progressText = document.getElementById('pricelist-create-progress-text');
const progressPercent = document.getElementById('pricelist-create-progress-percent');
const progressStats = document.getElementById('pricelist-create-progress-stats');
progressBox.classList.add('hidden');
progressBar.style.width = '0%';
progressText.textContent = 'Подготовка...';
progressPercent.textContent = '0%';
progressStats.textContent = '';
}
async function deletePricelist(id) {
// Check if online before deleting
const isOnline = await checkOnlineStatus();
if (!isOnline) {
showToast('Удаление прайслистов доступно только в онлайн режиме', 'error');
return;
}
if (!confirm('Удалить этот прайслист?')) return;
try {
const resp = await fetch(`/api/pricelists/${id}`, {
method: 'DELETE'
});
if (!resp.ok) {
const data = await resp.json();
throw new Error(data.error || 'Failed to delete');
}
showToast('Прайслист удален', 'success');
loadPricelists(pricelistsPage, currentPricelistSource);
} catch (e) {
showToast('Ошибка: ' + e.message, 'error');
}
}
document.getElementById('pricelists-create-form').addEventListener('submit', async function(e) {
e.preventDefault();
if (isCreatingPricelist) return; // protection from double-submit
isCreatingPricelist = true;
const submitBtn = this.querySelector('button[type="submit"]');
submitBtn.disabled = true;
submitBtn.textContent = 'Создание...';
try {
const pl = await createPricelist();
closePricelistsCreateModal();
showToast(`Прайслист ${pl.version} создан (${pl.item_count} позиций)`, 'success');
loadPricelists(1, currentPricelistSource);
} catch (e) {
showToast('Ошибка: ' + e.message, 'error');
} finally {
isCreatingPricelist = false;
submitBtn.disabled = false;
submitBtn.textContent = 'Создать';
}
});
</script>
{{end}}
{{template "base" .}}