Redesign configurator UI with tabs and remove Excel export
- Add tab-based configurator (Base, Storage, PCI, Power, Accessories, Other) - Base tab: single-select with autocomplete for MB, CPU, MEM - Other tabs: multi-select with autocomplete and quantity input - Table view with LOT, Description, Price, Quantity, Total columns - Add configuration list page with create modal (opportunity number) - Remove Excel export functionality and excelize dependency - Increase component list limit from 100 to 5000 - Add web templates (base, index, configs, login, admin_pricing) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
549
web/templates/admin_pricing.html
Normal file
549
web/templates/admin_pricing.html
Normal file
@@ -0,0 +1,549 @@
|
||||
{{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>
|
||||
</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 (only for components) -->
|
||||
<div id="search-bar" class="mb-4 hidden">
|
||||
<input type="text" id="search-input" placeholder="Поиск по артикулу..."
|
||||
class="w-full px-3 py-2 border rounded"
|
||||
onkeyup="debounceSearch()">
|
||||
</div>
|
||||
|
||||
<div id="tab-content">
|
||||
<div class="text-center py-8 text-gray-500">Загрузка...</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>
|
||||
<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">
|
||||
<option value="median">Медиана</option>
|
||||
<option value="average">Среднее</option>
|
||||
</select>
|
||||
</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="border-t pt-4">
|
||||
<label class="flex items-center mb-2">
|
||||
<input type="checkbox" id="modal-manual-enabled" class="mr-2" onchange="toggleManualPrice()">
|
||||
<span class="text-sm font-medium text-gray-700">Установить цену вручную</span>
|
||||
</label>
|
||||
<input type="number" id="modal-manual-price" step="0.01" class="w-full px-3 py-2 border rounded" placeholder="Цена USD" disabled>
|
||||
<p class="text-xs text-gray-500 mt-1">Ручная цена сохраняется при пересчёте</p>
|
||||
</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-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 = [];
|
||||
|
||||
async function loadTab(tab) {
|
||||
currentTab = tab;
|
||||
currentPage = 1;
|
||||
currentSearch = '';
|
||||
document.getElementById('search-input').value = '';
|
||||
|
||||
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('search-bar').className = tab === 'components' ? 'mb-4' : 'mb-4 hidden';
|
||||
document.getElementById('pagination').className = tab === 'components' ? 'flex justify-between items-center mt-4 pt-4 border-t' : 'hidden';
|
||||
|
||||
await loadData();
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) {
|
||||
window.location.href = '/login';
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById('tab-content').innerHTML = '<div class="text-center py-8 text-gray-500">Загрузка...</div>';
|
||||
|
||||
try {
|
||||
if (currentTab === 'alerts') {
|
||||
const resp = await fetch('/admin/pricing/alerts?per_page=100', {
|
||||
headers: {'Authorization': 'Bearer ' + token}
|
||||
});
|
||||
if (resp.status === 401) { logout(); return; }
|
||||
if (resp.status === 403) { window.location.href = '/'; return; }
|
||||
const data = await resp.json();
|
||||
renderAlerts(data.alerts || []);
|
||||
} else {
|
||||
let url = '/admin/pricing/components?page=' + currentPage + '&per_page=' + perPage;
|
||||
if (currentSearch) {
|
||||
url += '&search=' + encodeURIComponent(currentSearch);
|
||||
}
|
||||
const resp = await fetch(url, {
|
||||
headers: {'Authorization': 'Bearer ' + token}
|
||||
});
|
||||
if (resp.status === 401) { logout(); return; }
|
||||
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-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;
|
||||
|
||||
// Build settings summary
|
||||
let settings = [];
|
||||
const method = c.price_method || 'median';
|
||||
settings.push(method === 'median' ? 'М' : 'С');
|
||||
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 + 'д');
|
||||
if (c.price_coefficient && c.price_coefficient !== 0) {
|
||||
settings.push((c.price_coefficient > 0 ? '+' : '') + c.price_coefficient + '%');
|
||||
}
|
||||
if (c.manual_price && c.manual_price > 0) {
|
||||
settings.push('РУЧН');
|
||||
}
|
||||
|
||||
html += '<tr class="hover:bg-gray-50 cursor-pointer" onclick="openModal(' + idx + ')">';
|
||||
html += '<td class="px-3 py-2 text-sm font-mono">' + escapeHtml(c.lot_name) + '</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">' + 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">' + settings.join(' | ') + '</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-method').value = c.price_method || 'median';
|
||||
document.getElementById('modal-period').value = String(c.price_period_days !== undefined && c.price_period_days !== null ? c.price_period_days : 90);
|
||||
document.getElementById('modal-coefficient').value = c.price_coefficient || 0;
|
||||
|
||||
const hasManual = c.manual_price && c.manual_price > 0;
|
||||
document.getElementById('modal-manual-enabled').checked = hasManual;
|
||||
document.getElementById('modal-manual-price').value = hasManual ? c.manual_price : '';
|
||||
document.getElementById('modal-manual-price').disabled = !hasManual;
|
||||
|
||||
// Reset price displays while loading
|
||||
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();
|
||||
}
|
||||
|
||||
async function fetchPreview() {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) return;
|
||||
|
||||
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;
|
||||
|
||||
try {
|
||||
const resp = await fetch('/admin/pricing/preview', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': 'Bearer ' + token,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
lot_name: lotName,
|
||||
method: method,
|
||||
period_days: periodDays,
|
||||
coefficient: coefficient
|
||||
})
|
||||
});
|
||||
|
||||
if (resp.status === 401) { logout(); return; }
|
||||
|
||||
if (resp.ok) {
|
||||
const data = await resp.json();
|
||||
|
||||
// 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
|
||||
document.getElementById('modal-quote-count').textContent = data.quote_count || 0;
|
||||
}
|
||||
} catch(e) {
|
||||
console.error('Preview fetch error:', e);
|
||||
document.getElementById('modal-median-all').textContent = '—';
|
||||
document.getElementById('modal-new-price').textContent = '—';
|
||||
}
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
document.getElementById('price-modal').classList.add('hidden');
|
||||
document.getElementById('price-modal').classList.remove('flex');
|
||||
}
|
||||
|
||||
function toggleManualPrice() {
|
||||
const enabled = document.getElementById('modal-manual-enabled').checked;
|
||||
document.getElementById('modal-manual-price').disabled = !enabled;
|
||||
if (!enabled) {
|
||||
document.getElementById('modal-manual-price').value = '';
|
||||
}
|
||||
fetchPreview();
|
||||
}
|
||||
|
||||
// Debounce helper for preview updates
|
||||
let previewTimeout = null;
|
||||
function debounceFetchPreview() {
|
||||
clearTimeout(previewTimeout);
|
||||
previewTimeout = setTimeout(fetchPreview, 300);
|
||||
}
|
||||
|
||||
async function savePrice() {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) {
|
||||
window.location.href = '/login';
|
||||
return;
|
||||
}
|
||||
|
||||
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 = document.getElementById('modal-manual-enabled').checked;
|
||||
const manualPrice = manualEnabled ? parseFloat(document.getElementById('modal-manual-price').value) : null;
|
||||
|
||||
const body = {
|
||||
lot_name: lotName,
|
||||
method: method,
|
||||
period_days: periodDays,
|
||||
coefficient: coefficient,
|
||||
clear_manual: !manualEnabled
|
||||
};
|
||||
|
||||
if (manualEnabled && manualPrice > 0) {
|
||||
body.manual_price = manualPrice;
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await fetch('/admin/pricing/update', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': 'Bearer ' + token,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
|
||||
if (resp.status === 401) { logout(); return; }
|
||||
|
||||
if (resp.ok) {
|
||||
closeModal();
|
||||
loadData();
|
||||
} else {
|
||||
const data = await resp.json();
|
||||
alert('Ошибка: ' + (data.error || 'Неизвестная ошибка'));
|
||||
}
|
||||
} catch(e) {
|
||||
alert('Ошибка соединения');
|
||||
}
|
||||
}
|
||||
|
||||
function recalculateAll() {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) {
|
||||
window.location.href = '/login';
|
||||
return;
|
||||
}
|
||||
|
||||
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('/admin/pricing/recalculate-all', {
|
||||
method: 'POST',
|
||||
headers: {'Authorization': 'Bearer ' + token}
|
||||
}).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 = 'Обработка компонентов...';
|
||||
}
|
||||
|
||||
progressStats.textContent = 'Обновлено: ' + (data.updated || 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();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadTab('alerts');
|
||||
|
||||
// Add event listeners for preview updates
|
||||
document.getElementById('modal-method').addEventListener('change', fetchPreview);
|
||||
document.getElementById('modal-period').addEventListener('change', fetchPreview);
|
||||
document.getElementById('modal-coefficient').addEventListener('input', debounceFetchPreview);
|
||||
});
|
||||
</script>
|
||||
{{end}}
|
||||
|
||||
{{template "base" .}}
|
||||
Reference in New Issue
Block a user