Add meta component pricing functionality and admin UI enhancements
This commit is contained in:
@@ -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');
|
||||
|
||||
|
||||
Reference in New Issue
Block a user