Add meta component pricing functionality and admin UI enhancements

This commit is contained in:
Mikhail Chusavitin
2026-01-30 20:49:59 +03:00
parent d32b1c5d0c
commit 48921c699d
9 changed files with 428 additions and 29 deletions

View File

@@ -9,6 +9,7 @@
<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('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">
Пересчитать цены
@@ -76,6 +77,34 @@
<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 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>
<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">
@@ -103,11 +132,40 @@
<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>
<input type="checkbox" id="modal-meta-enabled" class="mr-2" onchange="toggleMetaPrice()">
<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 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>
<div class="bg-gray-50 p-3 rounded space-y-2">
@@ -153,8 +211,22 @@ async function loadTab(tab) {
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';
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
} 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
} else {
document.getElementById('search-bar').className = 'mb-4 hidden';
document.getElementById('pagination').className = 'hidden';
document.getElementById('btn-all-configs').className = 'text-gray-600 hidden';
}
await loadData();
}
@@ -177,6 +249,21 @@ async function loadData() {
if (resp.status === 403) { window.location.href = '/'; return; }
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, {
headers: {'Authorization': 'Bearer ' + token}
});
if (resp.status === 401) { logout(); return; }
if (resp.status === 403) { window.location.href = '/'; return; }
const data = await resp.json();
totalPages = Math.ceil(data.total / perPage);
renderAllConfigs(data.configurations || []);
updatePagination(data.total);
} else {
let url = '/admin/pricing/components?page=' + currentPage + '&per_page=' + perPage;
if (currentSearch) {
@@ -351,6 +438,16 @@ async function fetchPreview() {
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 = document.getElementById('modal-meta-method').value;
metaPeriod = parseInt(document.getElementById('modal-meta-period').value) || 0;
}
try {
const resp = await fetch('/admin/pricing/preview', {
@@ -363,7 +460,11 @@ async function fetchPreview() {
lot_name: lotName,
method: method,
period_days: periodDays,
coefficient: coefficient
coefficient: coefficient,
meta_enabled: metaEnabled,
meta_prices: metaPrices,
meta_method: metaMethod,
meta_period: metaPeriod
})
});
@@ -413,12 +514,39 @@ function closeModal() {
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, disable manual price
document.getElementById('modal-manual-enabled').checked = false;
document.getElementById('modal-manual-price').disabled = true;
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();
}
@@ -443,13 +571,28 @@ async function savePrice() {
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 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 = document.getElementById('modal-meta-method').value;
metaPeriod = parseInt(document.getElementById('modal-meta-period').value) || 0;
}
const body = {
lot_name: lotName,
method: method,
period_days: periodDays,
coefficient: coefficient,
clear_manual: !manualEnabled
clear_manual: !manualEnabled,
meta_enabled: metaEnabled,
meta_prices: metaPrices,
meta_method: metaMethod,
meta_period: metaPeriod
};
if (manualEnabled && manualPrice > 0) {
@@ -480,6 +623,35 @@ async function savePrice() {
}
}
// 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 token = localStorage.getItem('token');
if (!token) {
@@ -588,6 +760,57 @@ function toggleSortDir() {
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.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', () => {
loadTab('alerts');