Add hide component feature, usage indicators, and Docker support

- Add is_hidden field to hide components from configurator
- Add colored dot indicator showing component usage status:
  - Green: available in configurator
  - Cyan: used as source for meta-articles
  - Gray: hidden from configurator
- Optimize price recalculation with caching and skip unchanged
- Show current lot name during price recalculation
- Add Dockerfile (Alpine-based multi-stage build)
- Add docker-compose.yml and .dockerignore

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-30 22:49:11 +03:00
parent 48921c699d
commit 8309a5dc0e
9 changed files with 563 additions and 191 deletions

View File

@@ -81,37 +81,25 @@
<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 grid grid-cols-2 gap-4 mt-2">
<div>
<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">Артикулы, чьи цены будут использоваться в расчете. <br>Для автоматического подбора используйте * в конце названия (например: CPU_AMD_9654*)</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Метод расчета</label>
<select id="modal-meta-method" class="w-full px-3 py-2 border rounded">
<option value="median">Медиана</option>
<option value="average">Среднее</option>
<option value="weighted_median">Взвешенная медиана</option>
</select>
<label class="block text-sm font-medium text-gray-700 mb-1 mt-2">Период расчета</label>
<select id="modal-meta-period" class="w-full px-3 py-2 border rounded">
<option value="7">1 неделя</option>
<option value="30">1 месяц</option>
<option value="90" selected>1 квартал</option>
<option value="365">1 год</option>
<option value="0">Всё время</option>
</select>
</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">
<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>
@@ -130,42 +118,9 @@
<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-meta-enabled" class="mr-2" onchange="toggleMetaPrice()">
<span class="text-sm font-medium text-gray-700">Мета артикул</span>
</label>
<div id="meta-price-fields" class="hidden grid grid-cols-2 gap-4 mt-3">
<div>
<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">Артикулы, чьи цены будут использоваться в расчете. <br>Для автоматического подбора используйте * в конце названия (например: CPU_AMD_9654*)</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Метод расчета</label>
<select id="modal-meta-method" class="w-full px-3 py-2 border rounded">
<option value="median">Медиана</option>
<option value="average">Среднее</option>
<option value="weighted_median">Взвешенная медиана</option>
</select>
<label class="block text-sm font-medium text-gray-700 mb-1 mt-2">Период расчета</label>
<select id="modal-meta-period" class="w-full px-3 py-2 border rounded">
<option value="7">1 неделя</option>
<option value="30">1 месяц</option>
<option value="90" selected>1 квартал</option>
<option value="365">1 год</option>
<option value="0">Всё время</option>
</select>
</div>
</div>
<div class="mt-3">
<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="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">
@@ -360,33 +315,77 @@ function renderComponents(components, total) {
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 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('РУЧН');
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">' + escapeHtml(c.lot_name) + '</td>';
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">' + settings.join(' | ') + '</span></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>';
});
@@ -407,14 +406,41 @@ function openModal(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;
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 = '...';
@@ -430,6 +456,20 @@ function openModal(idx) {
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 token = localStorage.getItem('token');
if (!token) return;
@@ -445,8 +485,8 @@ async function fetchPreview() {
if (metaEnabled) {
metaPrices = document.getElementById('modal-meta-prices').value.trim();
metaMethod = document.getElementById('modal-meta-method').value;
metaPeriod = parseInt(document.getElementById('modal-meta-period').value) || 0;
metaMethod = method;
metaPeriod = periodDays;
}
try {
@@ -520,32 +560,18 @@ function toggleMetaPrice() {
fields.classList.toggle('hidden', !enabled);
if (enabled) {
// When enabling meta price, disable manual price
document.getElementById('modal-manual-enabled').checked = false;
document.getElementById('modal-manual-price').disabled = true;
document.getElementById('modal-manual-price').value = '';
// 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);
}
} else {
// When disabling meta price, reset to default values
// Don't change the main settings - they should stay as they were
}
fetchPreview();
}
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 = '';
}
// When enabling manual price, disable meta price
if (enabled) {
document.getElementById('modal-meta-enabled').checked = false;
document.getElementById('meta-price-fields').classList.add('hidden');
}
fetchPreview();
}
@@ -569,9 +595,12 @@ async function savePrice() {
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 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 = '';
@@ -579,20 +608,21 @@ async function savePrice() {
if (metaEnabled) {
metaPrices = document.getElementById('modal-meta-prices').value.trim();
metaMethod = document.getElementById('modal-meta-method').value;
metaPeriod = parseInt(document.getElementById('modal-meta-period').value) || 0;
metaMethod = manualEnabled ? 'median' : method;
metaPeriod = periodDays;
}
const body = {
lot_name: lotName,
method: method,
method: manualEnabled ? 'median' : method,
period_days: periodDays,
coefficient: coefficient,
clear_manual: !manualEnabled,
meta_enabled: metaEnabled,
meta_prices: metaPrices,
meta_method: metaMethod,
meta_period: metaPeriod
meta_period: metaPeriod,
is_hidden: isHidden
};
if (manualEnabled && manualPrice > 0) {
@@ -716,10 +746,10 @@ function recalculateAll() {
progressText.textContent = 'Пересчёт завершён!';
progressBar.className = 'bg-green-600 h-4 rounded-full';
} else {
progressText.textContent = 'Обработка компонентов...';
progressText.textContent = data.lot_name ? 'Обработка: ' + data.lot_name : 'Обработка компонентов...';
}
progressStats.textContent = 'Обновлено: ' + (data.updated || 0) + ' | Ручные: ' + (data.manual || 0) + ' | Нет данных: ' + (data.skipped || 0) + ' | Ошибок: ' + (data.errors || 0);
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);
}
@@ -815,9 +845,10 @@ 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);
document.getElementById('modal-manual-price').addEventListener('input', debounceFetchPreview);
document.getElementById('modal-meta-prices').addEventListener('input', debounceFetchPreview);
});
</script>
{{end}}