Apply remaining pricelist and local-first updates

This commit is contained in:
Mikhail Chusavitin
2026-02-06 13:01:40 +03:00
parent 1b48401828
commit e9307c4bad
19 changed files with 767 additions and 30 deletions

View File

@@ -13,14 +13,14 @@
<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-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">
@@ -94,6 +94,16 @@
Автор прайслиста: <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">
@@ -750,11 +760,11 @@ function recalculateAll() {
// Show progress bar IMMEDIATELY
btn.disabled = true;
btn.textContent = 'Пересчёт...';
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 = 'Запуск пересчёта...';
progressText.textContent = 'Запуск обновления...';
progressPercent.textContent = '0%';
progressStats.textContent = 'Подготовка...';
@@ -769,7 +779,7 @@ function recalculateAll() {
reader.read().then(({done, value}) => {
if (done) {
btn.disabled = false;
btn.textContent = 'Пересчитать цены';
btn.textContent = 'Обновить цены';
progressText.textContent = 'Готово!';
progressBar.className = 'bg-green-600 h-4 rounded-full';
setTimeout(() => {
@@ -794,7 +804,7 @@ function recalculateAll() {
progressPercent.textContent = percent + '%';
if (data.status === 'completed') {
progressText.textContent = 'Пересчёт завершён!';
progressText.textContent = 'Обновление завершено!';
progressBar.className = 'bg-green-600 h-4 rounded-full';
} else {
progressText.textContent = data.lot_name ? 'Обработка: ' + data.lot_name : 'Обработка компонентов...';
@@ -816,7 +826,7 @@ function recalculateAll() {
console.error('Fetch error:', e);
alert('Ошибка соединения');
btn.disabled = false;
btn.textContent = 'Пересчитать цены';
btn.textContent = 'Обновить цены';
progressContainer.style.display = 'none';
});
}
@@ -965,6 +975,10 @@ function renderPricelists(pricelists) {
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>`;
}
@@ -989,6 +1003,33 @@ function renderPricelists(pricelists) {
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) {
@@ -1024,6 +1065,7 @@ async function loadPricelistsDbUsername() {
function openPricelistsCreateModal() {
document.getElementById('pricelists-create-modal').classList.remove('hidden');
document.getElementById('pricelists-create-modal').classList.add('flex');
resetPricelistCreateProgress();
loadPricelistsDbUsername();
}
@@ -1049,20 +1091,88 @@ async function createPricelist() {
throw new Error('Создание прайслистов доступно только в онлайн режиме');
}
const resp = await fetch('/api/pricelists', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({})
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) {
const data = await resp.json();
let data = {};
try { data = await resp.json(); } catch (_) {}
throw new Error(data.error || 'Failed to create pricelist');
}
return await resp.json();
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) {

View File

@@ -16,7 +16,7 @@
</div>
<div id="save-buttons" class="hidden flex items-center space-x-2">
<button onclick="refreshPrices()" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">
Пересчитать цену
Обновить цены
</button>
<button onclick="saveConfig()" class="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700">
Сохранить
@@ -34,6 +34,14 @@
class="w-20 px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500"
onchange="updateServerCount()">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Прайслист</label>
<select id="pricelist-select"
class="w-56 px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500"
onchange="updatePricelistSelection()">
<option value="">Загрузка...</option>
</select>
</div>
<div class="text-sm text-gray-500">
<span id="server-count-info">Всего: <span id="total-server-count">1</span> сервер(а)</span>
</div>
@@ -224,6 +232,7 @@ let cart = [];
let categoryOrderMap = {}; // Category code -> display_order mapping
let autoSaveTimeout = null; // Timeout for debounced autosave
let serverCount = 1; // Server count for the configuration
let selectedPricelistId = null; // Selected pricelist (server ID)
// Autocomplete state
let autocompleteInput = null;
@@ -296,6 +305,7 @@ document.addEventListener('DOMContentLoaded', async function() {
serverCount = config.server_count || 1;
document.getElementById('server-count').value = serverCount;
document.getElementById('total-server-count').textContent = serverCount;
selectedPricelistId = config.pricelist_id || null;
if (config.items && config.items.length > 0) {
cart = config.items.map(item => ({
@@ -322,6 +332,7 @@ document.addEventListener('DOMContentLoaded', async function() {
return;
}
await loadActivePricelists();
await loadAllComponents();
renderTab();
updateCartUI();
@@ -361,6 +372,44 @@ function updateServerCount() {
triggerAutoSave();
}
async function loadActivePricelists() {
const select = document.getElementById('pricelist-select');
if (!select) return;
try {
const resp = await fetch('/api/pricelists?active_only=true&per_page=200');
const data = await resp.json();
const pricelists = data.pricelists || [];
if (pricelists.length === 0) {
select.innerHTML = '<option value="">Нет активных прайслистов</option>';
selectedPricelistId = null;
return;
}
select.innerHTML = pricelists.map(pl => {
return `<option value="${pl.id}">${pl.version}</option>`;
}).join('');
const existing = selectedPricelistId && pricelists.some(pl => Number(pl.id) === Number(selectedPricelistId));
if (existing) {
select.value = String(selectedPricelistId);
} else {
selectedPricelistId = Number(pricelists[0].id);
select.value = String(selectedPricelistId);
}
} catch (e) {
select.innerHTML = '<option value="">Ошибка загрузки</option>';
}
}
function updatePricelistSelection() {
const select = document.getElementById('pricelist-select');
const next = parseInt(select.value);
selectedPricelistId = Number.isFinite(next) && next > 0 ? next : null;
triggerAutoSave();
}
function getCategoryFromLotName(lotName) {
const parts = lotName.split('_');
return parts[0] || '';
@@ -1133,7 +1182,8 @@ async function saveConfig(showNotification = true) {
items: cart,
custom_price: customPrice,
notes: '',
server_count: serverCountValue
server_count: serverCountValue,
pricelist_id: selectedPricelistId
})
});
@@ -1327,6 +1377,17 @@ async function refreshPrices() {
if (config.price_updated_at) {
updatePriceUpdateDate(config.price_updated_at);
}
if (config.pricelist_id) {
selectedPricelistId = config.pricelist_id;
const select = document.getElementById('pricelist-select');
if (select) {
const hasOption = Array.from(select.options).some(opt => Number(opt.value) === Number(selectedPricelistId));
if (!hasOption) {
await loadActivePricelists();
}
select.value = String(selectedPricelistId);
}
}
// Re-render UI
renderTab();

View File

@@ -147,12 +147,15 @@
let settings = [];
const hasManualPrice = item.manual_price && item.manual_price > 0;
const hasMeta = item.meta_prices && item.meta_prices.trim() !== '';
const method = (item.price_method || '').toLowerCase();
// Method indicator
if (hasManualPrice) {
settings.push('<span class="text-orange-600 font-medium">РУЧН</span>');
} else if (item.price_method === 'average') {
} else if (method === 'average') {
settings.push('Сред');
} else if (method === 'weighted_median') {
settings.push('Взвеш. мед');
} else {
settings.push('Мед');
}