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');
|
||||
|
||||
|
||||
@@ -4,15 +4,24 @@
|
||||
<div class="space-y-4">
|
||||
<h1 class="text-2xl font-bold">Мои конфигурации</h1>
|
||||
|
||||
<div id="configs-list">
|
||||
<div class="text-center py-8 text-gray-500">Загрузка...</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<button onclick="openCreateModal()" class="w-full py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium">
|
||||
+ Создать новую конфигурацию
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="configs-list">
|
||||
<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>
|
||||
|
||||
<!-- Modal for creating new configuration -->
|
||||
@@ -132,8 +141,10 @@ function renderConfigs(configs) {
|
||||
|
||||
let html = '<div class="bg-white rounded-lg shadow overflow-hidden"><table class="w-full">';
|
||||
html += '<thead class="bg-gray-50"><tr>';
|
||||
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Название</th>';
|
||||
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Дата</th>';
|
||||
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Название</th>';
|
||||
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Цена (за 1 шт)</th>';
|
||||
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Кол-во</th>';
|
||||
html += '<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Сумма</th>';
|
||||
html += '<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Действия</th>';
|
||||
html += '</tr></thead><tbody class="divide-y">';
|
||||
@@ -141,14 +152,37 @@ function renderConfigs(configs) {
|
||||
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;
|
||||
|
||||
// Calculate price per unit (total / server count)
|
||||
let pricePerUnit = '—';
|
||||
if (c.total_price && serverCount > 0) {
|
||||
const unitPrice = c.total_price / serverCount;
|
||||
pricePerUnit = '$' + unitPrice.toLocaleString('en-US', {minimumFractionDigits: 2});
|
||||
}
|
||||
|
||||
html += '<tr class="hover:bg-gray-50">';
|
||||
html += '<td class="px-4 py-3 text-sm font-medium"><a href="/configurator?uuid=' + c.uuid + '" class="text-blue-600 hover:text-blue-800 hover:underline">' + escapeHtml(c.name) + '</a></td>';
|
||||
html += '<td class="px-4 py-3 text-sm text-gray-500">' + date + '</td>';
|
||||
html += '<td class="px-4 py-3 text-sm font-medium"><a href="/configurator?uuid=' + c.uuid + '" class="text-blue-600 hover:text-blue-800 hover:underline">' + escapeHtml(c.name) + '</a></td>';
|
||||
html += '<td class="px-4 py-3 text-sm text-gray-500">' + pricePerUnit + '</td>';
|
||||
html += '<td class="px-4 py-3 text-sm text-gray-500">' + serverCount + '</td>';
|
||||
html += '<td class="px-4 py-3 text-sm text-right">' + total + '</td>';
|
||||
html += '<td class="px-4 py-3 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">Копировать</button>';
|
||||
html += '<button onclick="openRenameModal(\'' + c.uuid + '\', \'' + escapeHtml(c.name).replace(/'/g, "\\'") + '\')" class="text-blue-600 hover:text-blue-800">Переименовать</button>';
|
||||
html += '<button onclick="deleteConfig(\'' + c.uuid + '\')" class="text-red-600 hover:text-red-800">Удалить</button>';
|
||||
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>';
|
||||
});
|
||||
|
||||
@@ -322,7 +356,8 @@ async function createConfig() {
|
||||
body: JSON.stringify({
|
||||
name: name,
|
||||
items: [],
|
||||
notes: ''
|
||||
notes: '',
|
||||
server_count: 1
|
||||
})
|
||||
});
|
||||
|
||||
@@ -386,6 +421,69 @@ document.getElementById('clone-input').addEventListener('keydown', function(e) {
|
||||
}
|
||||
});
|
||||
|
||||
// Pagination functions
|
||||
let currentPage = 1;
|
||||
let totalPages = 1;
|
||||
let perPage = 20;
|
||||
|
||||
function prevPage() {
|
||||
if (currentPage > 1) {
|
||||
currentPage--;
|
||||
loadConfigs();
|
||||
}
|
||||
}
|
||||
|
||||
function nextPage() {
|
||||
if (currentPage < totalPages) {
|
||||
currentPage++;
|
||||
loadConfigs();
|
||||
}
|
||||
}
|
||||
|
||||
function updatePagination(total) {
|
||||
totalPages = Math.ceil(total / perPage);
|
||||
document.getElementById('page-info').textContent =
|
||||
'Страница ' + currentPage + ' из ' + totalPages + ' (всего: ' + total + ')';
|
||||
document.getElementById('btn-prev').disabled = currentPage <= 1;
|
||||
document.getElementById('btn-next').disabled = currentPage >= totalPages;
|
||||
document.getElementById('pagination').classList.remove('hidden');
|
||||
}
|
||||
|
||||
// Load configs with pagination
|
||||
async function loadConfigs() {
|
||||
const token = localStorage.getItem('token');
|
||||
|
||||
if (!token) {
|
||||
document.getElementById('configs-list').innerHTML =
|
||||
'<div class="bg-white rounded-lg shadow p-8 text-center"><a href="/login" class="text-blue-600">Войдите для просмотра</a></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/configs?page=' + currentPage + '&per_page=' + perPage, {
|
||||
headers: {'Authorization': 'Bearer ' + token}
|
||||
});
|
||||
|
||||
if (resp.status === 401) {
|
||||
logout();
|
||||
return;
|
||||
}
|
||||
|
||||
if (resp.status === 403) {
|
||||
document.getElementById('configs-list').innerHTML =
|
||||
'<div class="bg-white rounded-lg shadow p-8 text-center text-red-600">Нет доступа</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await resp.json();
|
||||
renderConfigs(data.configurations || []);
|
||||
updatePagination(data.total);
|
||||
} catch(e) {
|
||||
document.getElementById('configs-list').innerHTML =
|
||||
'<div class="bg-white rounded-lg shadow p-8 text-center text-red-600">Ошибка загрузки</div>';
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', loadConfigs);
|
||||
</script>
|
||||
{{end}}
|
||||
|
||||
@@ -21,6 +21,21 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Server count input -->
|
||||
<div class="bg-white rounded-lg shadow p-4 mb-4">
|
||||
<div class="flex items-center space-x-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Количество серверов</label>
|
||||
<input type="number" id="server-count" min="1" value="1"
|
||||
class="w-20 px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500"
|
||||
onchange="updateServerCount()">
|
||||
</div>
|
||||
<div class="text-sm text-gray-500">
|
||||
<span id="server-count-info">Всего: <span id="total-server-count">1</span> сервер(а)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Category Tabs -->
|
||||
<div class="bg-white rounded-lg shadow">
|
||||
<div class="border-b">
|
||||
@@ -204,6 +219,7 @@ let allComponents = [];
|
||||
let cart = [];
|
||||
let categoryOrderMap = {}; // Category code -> display_order mapping
|
||||
let autoSaveTimeout = null; // Timeout for debounced autosave
|
||||
let serverCount = 1; // Server count for the configuration
|
||||
|
||||
// Autocomplete state
|
||||
let autocompleteInput = null;
|
||||
@@ -280,6 +296,11 @@ document.addEventListener('DOMContentLoaded', async function() {
|
||||
document.getElementById('config-name').textContent = config.name;
|
||||
document.getElementById('save-buttons').classList.remove('hidden');
|
||||
|
||||
// Set server count from config
|
||||
serverCount = config.server_count || 1;
|
||||
document.getElementById('server-count').value = serverCount;
|
||||
document.getElementById('total-server-count').textContent = serverCount;
|
||||
|
||||
if (config.items && config.items.length > 0) {
|
||||
cart = config.items.map(item => ({
|
||||
lot_name: item.lot_name,
|
||||
@@ -323,6 +344,22 @@ async function loadAllComponents() {
|
||||
}
|
||||
}
|
||||
|
||||
function updateServerCount() {
|
||||
const serverCountInput = document.getElementById('server-count');
|
||||
const newCount = parseInt(serverCountInput.value) || 1;
|
||||
serverCount = Math.max(1, newCount);
|
||||
serverCountInput.value = serverCount;
|
||||
|
||||
// Update total server count display
|
||||
document.getElementById('total-server-count').textContent = serverCount;
|
||||
|
||||
// Update cart UI to reflect the server count
|
||||
updateCartUI();
|
||||
|
||||
// Trigger auto-save
|
||||
triggerAutoSave();
|
||||
}
|
||||
|
||||
function getCategoryFromLotName(lotName) {
|
||||
const parts = lotName.split('_');
|
||||
return parts[0] || '';
|
||||
@@ -1081,6 +1118,9 @@ async function saveConfig(showNotification = true) {
|
||||
const customPriceValue = parseFloat(customPriceInput.value);
|
||||
const customPrice = customPriceValue > 0 ? customPriceValue : null;
|
||||
|
||||
// Get server count
|
||||
const serverCountValue = serverCount;
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/configs/' + configUUID, {
|
||||
method: 'PUT',
|
||||
@@ -1092,7 +1132,8 @@ async function saveConfig(showNotification = true) {
|
||||
name: configName,
|
||||
items: cart,
|
||||
custom_price: customPrice,
|
||||
notes: ''
|
||||
notes: '',
|
||||
server_count: serverCountValue
|
||||
})
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user