1377 lines
63 KiB
HTML
1377 lines
63 KiB
HTML
{{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('alerts')" id="btn-alerts" class="text-blue-600 font-medium">Алерты</button>
|
||
<button onclick="loadTab('components')" id="btn-components" class="text-gray-600">Компоненты</button>
|
||
<button onclick="loadTab('pricelists')" id="btn-pricelists" 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 (only for components) -->
|
||
<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="popularity_score" selected>Популярность</option>
|
||
<option value="quote_count">Кол-во котировок</option>
|
||
<option value="current_price">Цена</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">
|
||
<h2 class="text-xl font-semibold">Прайслисты</h2>
|
||
<div id="pricelists-create-btn-container"></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-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="7" 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>
|
||
|
||
<!-- 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">×</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 = 'alerts';
|
||
let currentPage = 1;
|
||
let totalPages = 1;
|
||
let perPage = 50;
|
||
let searchTimeout = null;
|
||
let currentSearch = '';
|
||
let componentsCache = [];
|
||
let sortField = 'popularity_score';
|
||
let sortDir = 'desc';
|
||
let pricelistsPage = 1;
|
||
let pricelistsCanWrite = false;
|
||
let isCreatingPricelist = false;
|
||
let cachedDbUsername = null;
|
||
let syncUsersStatusTimer = null;
|
||
|
||
async function loadTab(tab) {
|
||
currentTab = tab;
|
||
currentPage = 1;
|
||
currentSearch = '';
|
||
document.getElementById('search-input').value = '';
|
||
if (tab !== 'sync-status') {
|
||
stopSyncUsersStatusRefresh();
|
||
}
|
||
|
||
document.getElementById('btn-alerts').className = tab === 'alerts' ? 'text-blue-600 font-medium' : 'text-gray-600';
|
||
document.getElementById('btn-components').className = tab === 'components' ? '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-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';
|
||
|
||
// Show/hide elements based on tab
|
||
if (tab === 'components') {
|
||
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'; // Hide this tab for components
|
||
document.getElementById('pricelists-tab-content').className = 'hidden';
|
||
document.getElementById('sync-status-tab-content').className = 'hidden';
|
||
document.getElementById('tab-content').className = '';
|
||
} else if (tab === 'all-configs') {
|
||
document.getElementById('search-bar').className = 'mb-4 hidden'; // Hide search for all configs
|
||
document.getElementById('pagination').className = 'flex justify-between items-center mt-4 pt-4 border-t'; // Show pagination
|
||
document.getElementById('btn-all-configs').className = 'text-blue-600 font-medium'; // Show this tab for all configs
|
||
document.getElementById('pricelists-tab-content').className = 'hidden';
|
||
document.getElementById('sync-status-tab-content').className = 'hidden';
|
||
document.getElementById('tab-content').className = '';
|
||
} else if (tab === 'pricelists') {
|
||
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';
|
||
// Load pricelists when pricelists tab is selected
|
||
checkPricelistWritePermission();
|
||
loadPricelists(1);
|
||
} 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('alerts');
|
||
return;
|
||
}
|
||
await loadUsersSyncStatus();
|
||
startSyncUsersStatusRefresh();
|
||
} 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 = '';
|
||
}
|
||
|
||
if (tab !== 'pricelists' && tab !== 'sync-status') {
|
||
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 === 'alerts') {
|
||
const resp = await fetch('/api/admin/pricing/alerts?per_page=100');
|
||
const data = await resp.json();
|
||
renderAlerts(data.alerts || []);
|
||
} 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 {
|
||
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);
|
||
}
|
||
} 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 renderAlerts(alerts) {
|
||
if (alerts.length === 0) {
|
||
document.getElementById('tab-content').innerHTML = '<div class="text-center py-8 text-green-600">Нет активных алертов</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>';
|
||
});
|
||
html += '</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 === 'components') {
|
||
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;
|
||
}
|
||
|
||
document.addEventListener('DOMContentLoaded', async () => {
|
||
await checkPricelistWritePermission();
|
||
// Check URL params for initial tab
|
||
const urlParams = new URLSearchParams(window.location.search);
|
||
const initialTab = urlParams.get('tab') || 'alerts';
|
||
await loadTab(initialTab);
|
||
|
||
// 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);
|
||
});
|
||
|
||
// Pricelists functions
|
||
let canWrite = false;
|
||
|
||
async function checkPricelistWritePermission() {
|
||
try {
|
||
const resp = await fetch('/api/pricelists/can-write');
|
||
const data = await resp.json();
|
||
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>
|
||
`;
|
||
document.getElementById('btn-sync-status').classList.remove('hidden');
|
||
if (currentTab === 'sync-status') {
|
||
await loadUsersSyncStatus();
|
||
startSyncUsersStatusRefresh();
|
||
}
|
||
} else {
|
||
document.getElementById('pricelists-create-btn-container').innerHTML = '';
|
||
document.getElementById('btn-sync-status').classList.add('hidden');
|
||
stopSyncUsersStatusRefresh();
|
||
if (currentTab === 'sync-status') {
|
||
await loadTab('alerts');
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.error('Failed to check pricelist write permission:', e);
|
||
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) {
|
||
pricelistsPage = page;
|
||
try {
|
||
const resp = await fetch(`/api/pricelists?page=${page}&per_page=20`);
|
||
const data = await resp.json();
|
||
|
||
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">
|
||
Ошибка загрузки: ${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="7" 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 ? 'Активен' : 'Неактивен';
|
||
|
||
let actions = `<a href="/pricelists/${pl.id}" class="text-blue-600 hover:text-blue-800 text-sm">Просмотр</a>`;
|
||
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>`;
|
||
}
|
||
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>`;
|
||
}
|
||
|
||
return `
|
||
<tr class="hover:bg-gray-50">
|
||
<td class="px-6 py-4 whitespace-nowrap">
|
||
<span class="font-mono text-sm">${pl.version}</span>
|
||
</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">${actions}</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);
|
||
} 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})" 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() {
|
||
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() {
|
||
// 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'
|
||
});
|
||
|
||
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);
|
||
} 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);
|
||
} catch (e) {
|
||
showToast('Ошибка: ' + e.message, 'error');
|
||
} finally {
|
||
isCreatingPricelist = false;
|
||
submitBtn.disabled = false;
|
||
submitBtn.textContent = 'Создать';
|
||
}
|
||
});
|
||
</script>
|
||
{{end}}
|
||
|
||
{{template "base" .}}
|