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

@@ -161,6 +161,10 @@ type UpdatePriceRequest struct {
Coefficient float64 `json:"coefficient"`
ManualPrice *float64 `json:"manual_price"`
ClearManual bool `json:"clear_manual"`
MetaEnabled bool `json:"meta_enabled"`
MetaPrices string `json:"meta_prices"`
MetaMethod string `json:"meta_method"`
MetaPeriod int `json:"meta_period"`
}
func (h *PricingHandler) UpdatePrice(c *gin.Context) {

View File

@@ -49,6 +49,7 @@ type Configuration struct {
CustomPrice *float64 `gorm:"type:decimal(12,2)" json:"custom_price"`
Notes string `gorm:"type:text" json:"notes"`
IsTemplate bool `gorm:"default:false" json:"is_template"`
ServerCount int `gorm:"default:1" json:"server_count"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
User *User `gorm:"foreignKey:UserID" json:"user,omitempty"`

View File

@@ -48,6 +48,9 @@ type LotMetadata struct {
RequestCount int `gorm:"default:0" json:"request_count"`
LastRequestDate *time.Time `gorm:"type:date" json:"last_request_date"`
PopularityScore float64 `gorm:"type:decimal(10,4);default:0" json:"popularity_score"`
MetaPrices string `gorm:"size:1000" json:"meta_prices"`
MetaMethod string `gorm:"size:20" json:"meta_method"`
MetaPeriodDays int `gorm:"default:90" json:"meta_period_days"`
// Relations
Lot *Lot `gorm:"foreignKey:LotName;references:LotName" json:"lot,omitempty"`

View File

@@ -37,11 +37,17 @@ type CreateConfigRequest struct {
CustomPrice *float64 `json:"custom_price"`
Notes string `json:"notes"`
IsTemplate bool `json:"is_template"`
ServerCount int `json:"server_count"`
}
func (s *ConfigurationService) Create(userID uint, req *CreateConfigRequest) (*models.Configuration, error) {
total := req.Items.Total()
// If server count is greater than 1, multiply the total by server count
if req.ServerCount > 1 {
total *= float64(req.ServerCount)
}
config := &models.Configuration{
UUID: uuid.New().String(),
UserID: userID,
@@ -51,6 +57,7 @@ func (s *ConfigurationService) Create(userID uint, req *CreateConfigRequest) (*m
CustomPrice: req.CustomPrice,
Notes: req.Notes,
IsTemplate: req.IsTemplate,
ServerCount: req.ServerCount,
}
if err := s.configRepo.Create(config); err != nil {
@@ -89,12 +96,18 @@ func (s *ConfigurationService) Update(uuid string, userID uint, req *CreateConfi
total := req.Items.Total()
// If server count is greater than 1, multiply the total by server count
if req.ServerCount > 1 {
total *= float64(req.ServerCount)
}
config.Name = req.Name
config.Items = req.Items
config.TotalPrice = &total
config.CustomPrice = req.CustomPrice
config.Notes = req.Notes
config.IsTemplate = req.IsTemplate
config.ServerCount = req.ServerCount
if err := s.configRepo.Update(config); err != nil {
return nil, err
@@ -144,6 +157,11 @@ func (s *ConfigurationService) Clone(configUUID string, userID uint, newName str
// Create copy with new UUID and name
total := original.Items.Total()
// If server count is greater than 1, multiply the total by server count
if original.ServerCount > 1 {
total *= float64(original.ServerCount)
}
clone := &models.Configuration{
UUID: uuid.New().String(),
UserID: userID,
@@ -153,6 +171,7 @@ func (s *ConfigurationService) Clone(configUUID string, userID uint, newName str
CustomPrice: original.CustomPrice,
Notes: original.Notes,
IsTemplate: false, // Clone is never a template
ServerCount: original.ServerCount,
}
if err := s.configRepo.Create(clone); err != nil {

BIN
server Executable file

Binary file not shown.

10
test_export.csv Normal file
View File

@@ -0,0 +1,10 @@
Артикул,Описание,Категория,Количество,Цена за единицу,Сумма
CPU_Intel_8462Y+,,CPU,2,1940.00,3880.00
GPU_NV_H200_141GB_SXM_(HGX),,GPU,8,27771.75,222174.00
MB_INTEL_4.Saphire(Emerald)Rapids_2S_32xDDR5_HGX8,,MB,1,2816.19,2816.19
MEM_DDR5_64G_5600,,MEM,32,2556.00,81792.00
NIC_2p100G_MCX623106##,,NIC,2,737.50,1475.00
PS_2700W_Titanium,,PS,6,561.00,3366.00
PS_3200W_Titanium,,PS,2,1014.79,2029.58
SSD_NVME_01.92T,,SSD,5,253.00,1265.00
,,,,ИТОГО:,318797.77
1 Артикул Описание Категория Количество Цена за единицу Сумма
2 CPU_Intel_8462Y+ CPU 2 1940.00 3880.00
3 GPU_NV_H200_141GB_SXM_(HGX) GPU 8 27771.75 222174.00
4 MB_INTEL_4.Saphire(Emerald)Rapids_2S_32xDDR5_HGX8 MB 1 2816.19 2816.19
5 MEM_DDR5_64G_5600 MEM 32 2556.00 81792.00
6 NIC_2p100G_MCX623106## NIC 2 737.50 1475.00
7 PS_2700W_Titanium PS 6 561.00 3366.00
8 PS_3200W_Titanium PS 2 1014.79 2029.58
9 SSD_NVME_01.92T SSD 5 253.00 1265.00
10 ИТОГО: 318797.77

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">
@@ -102,6 +131,34 @@
</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>
@@ -109,6 +166,7 @@
<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">
<div class="text-sm font-medium text-gray-700 mb-2">Расчёт цены</div>
@@ -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');

View File

@@ -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}}

View File

@@ -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
})
});