Files
QuoteForge/web/templates/index.html
Michael Chus 143d217397 Add Phase 2: Local SQLite database with sync functionality
Implements complete offline-first architecture with SQLite caching and MariaDB synchronization.

Key features:
- Local SQLite database for offline operation (data/quoteforge.db)
- Connection settings with encrypted credentials
- Component and pricelist caching with auto-sync
- Sync API endpoints (/api/sync/status, /components, /pricelists, /all)
- Real-time sync status indicator in UI with auto-refresh
- Offline mode detection middleware
- Migration tool for database initialization
- Setup wizard for initial configuration

New components:
- internal/localdb: SQLite repository layer (components, pricelists, sync)
- internal/services/sync: Synchronization service
- internal/handlers/sync: Sync API handlers
- internal/handlers/setup: Setup wizard handlers
- internal/middleware/offline: Offline detection
- cmd/migrate: Database migration tool

UI improvements:
- Setup page for database configuration
- Sync status indicator with online/offline detection
- Warning icons for pending synchronization
- Auto-refresh every 30 seconds

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-01 11:00:32 +03:00

1374 lines
52 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{{define "title"}}QuoteForge - Конфигуратор{{end}}
{{define "content"}}
<div class="space-y-4">
<!-- Header with config name and back button -->
<div class="flex items-center justify-between">
<div class="flex items-center space-x-4">
<a href="/configs" class="text-gray-500 hover:text-gray-700">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path>
</svg>
</a>
<h1 class="text-2xl font-bold">
<span id="config-name">Конфигуратор</span>
</h1>
</div>
<div id="save-buttons" class="hidden flex items-center space-x-2">
<button onclick="refreshPrices()" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">
Пересчитать цену
</button>
<button onclick="saveConfig()" class="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700">
Сохранить
</button>
<span id="price-update-date" class="text-sm text-gray-500"></span>
</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">
<nav class="flex overflow-x-auto" id="category-tabs">
<button onclick="switchTab('base')" data-tab="base"
class="tab-btn px-4 py-3 text-sm font-medium border-b-2 border-blue-600 text-blue-600 whitespace-nowrap">
Base
</button>
<button onclick="switchTab('storage')" data-tab="storage"
class="tab-btn px-4 py-3 text-sm font-medium border-b-2 border-transparent text-gray-500 hover:text-gray-700 whitespace-nowrap">
Storage
</button>
<button onclick="switchTab('pci')" data-tab="pci"
class="tab-btn px-4 py-3 text-sm font-medium border-b-2 border-transparent text-gray-500 hover:text-gray-700 whitespace-nowrap">
PCI
</button>
<button onclick="switchTab('power')" data-tab="power"
class="tab-btn px-4 py-3 text-sm font-medium border-b-2 border-transparent text-gray-500 hover:text-gray-700 whitespace-nowrap">
Power
</button>
<button onclick="switchTab('accessories')" data-tab="accessories"
class="tab-btn px-4 py-3 text-sm font-medium border-b-2 border-transparent text-gray-500 hover:text-gray-700 whitespace-nowrap">
Accessories
</button>
<button onclick="switchTab('other')" data-tab="other"
class="tab-btn px-4 py-3 text-sm font-medium border-b-2 border-transparent text-gray-500 hover:text-gray-700 whitespace-nowrap">
Other
</button>
</nav>
</div>
<!-- Tab content -->
<div id="tab-content" class="p-4">
<div class="text-center py-8 text-gray-500">Загрузка...</div>
</div>
</div>
<!-- Cart summary -->
<div id="cart-summary" class="bg-white rounded-lg shadow p-4">
<h3 class="font-semibold mb-3">Итого конфигурация</h3>
<div id="cart-items" class="space-y-2 mb-4"></div>
<div class="border-t pt-3 flex justify-between items-center">
<div class="text-lg font-bold">
Итого: <span id="cart-total">$0.00</span>
</div>
<button onclick="exportCSV()" class="px-3 py-1 bg-gray-200 text-gray-700 rounded text-sm hover:bg-gray-300">Экспорт CSV</button>
</div>
</div>
<!-- Custom price section -->
<div id="custom-price-section" class="bg-white rounded-lg shadow p-4">
<h3 class="font-semibold mb-3">Своя цена</h3>
<div class="flex items-center gap-4 mb-4">
<div class="flex-1">
<label class="block text-sm text-gray-600 mb-1">Введите целевую цену</label>
<div class="flex items-center gap-2">
<span class="text-gray-500">$</span>
<input type="number" id="custom-price-input" step="0.01" min="0"
placeholder="0.00"
class="flex-1 px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500"
oninput="calculateCustomPrice(); triggerAutoSave();">
<button onclick="clearCustomPrice()" class="px-3 py-2 text-gray-500 hover:text-gray-700">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
</div>
<div id="discount-info" class="text-right hidden">
<div class="text-sm text-gray-600">Скидка</div>
<div class="text-2xl font-bold text-green-600" id="discount-percent">0%</div>
</div>
</div>
<!-- Adjusted prices table -->
<div id="adjusted-prices" class="hidden">
<div class="border-t pt-3">
<h4 class="text-sm font-medium text-gray-700 mb-2">Скорректированные цены</h4>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-gray-50">
<tr>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Компонент</th>
<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase">Кол-во</th>
<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase">Было</th>
<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase">Стало</th>
<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase">Итого</th>
</tr>
</thead>
<tbody id="adjusted-prices-body" class="divide-y"></tbody>
<tfoot class="bg-gray-50 font-medium">
<tr>
<td class="px-3 py-2" colspan="2">Итого</td>
<td class="px-3 py-2 text-right" id="adjusted-total-original">$0.00</td>
<td class="px-3 py-2 text-right text-green-600" id="adjusted-total-new">$0.00</td>
<td class="px-3 py-2 text-right text-green-600" id="adjusted-total-final">$0.00</td>
</tr>
</tfoot>
</table>
</div>
</div>
<div class="mt-3 flex justify-end">
<button onclick="exportCSVWithCustomPrice()" class="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700">
Экспорт CSV со скидкой
</button>
</div>
</div>
</div>
</div>
<!-- Autocomplete dropdown (shared) -->
<div id="autocomplete-dropdown" class="hidden absolute z-50 bg-white border rounded-lg shadow-lg max-h-96 overflow-y-auto w-96"></div>
<style>
.autocomplete-item {
padding: 8px 12px;
cursor: pointer;
border-bottom: 1px solid #f0f0f0;
}
.autocomplete-item:hover, .autocomplete-item.selected {
background-color: #f3f4f6;
}
.autocomplete-item:last-child {
border-bottom: none;
}
</style>
<script>
// Tab configuration - will be populated dynamically
let TAB_CONFIG = {
base: {
categories: ['MB', 'CPU', 'MEM'],
singleSelect: true,
label: 'Base'
},
storage: {
categories: ['RAID', 'M2', 'SSD', 'HDD', 'EDSFF', 'HHHL'],
singleSelect: false,
label: 'Storage',
sections: [
{ title: 'RAID Контроллеры', categories: ['RAID'] },
{ title: 'Диски', categories: ['M2', 'SSD', 'HDD', 'EDSFF', 'HHHL'] }
]
},
pci: {
categories: ['GPU', 'DPU', 'NIC', 'HCA', 'HBA'],
singleSelect: false,
label: 'PCI',
sections: [
{ title: 'GPU / DPU', categories: ['GPU', 'DPU'] },
{ title: 'NIC / HCA', categories: ['NIC', 'HCA'] },
{ title: 'HBA', categories: ['HBA'] }
]
},
power: {
categories: ['PS', 'PSU'],
singleSelect: false,
label: 'Power'
},
accessories: {
categories: ['ACC', 'CARD'],
singleSelect: false,
label: 'Accessories'
},
other: {
categories: [],
singleSelect: false,
label: 'Other'
}
};
let ASSIGNED_CATEGORIES = Object.values(TAB_CONFIG)
.flatMap(t => t.categories)
.map(c => c.toUpperCase());
// State
let configUUID = '{{.ConfigUUID}}';
let configName = '';
let currentTab = 'base';
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;
let autocompleteCategory = null;
let autocompleteMode = null; // 'single', 'multi', 'section'
let autocompleteIndex = -1;
let autocompleteFiltered = [];
// Load categories from API and update tab configuration
async function loadCategoriesFromAPI() {
try {
const resp = await fetch('/api/categories');
const cats = await resp.json();
// Build category order map
categoryOrderMap = {};
cats.forEach(cat => {
categoryOrderMap[cat.code.toUpperCase()] = cat.display_order;
});
// Build list of unassigned categories
const knownCodes = Object.values(TAB_CONFIG)
.flatMap(t => t.categories)
.map(c => c.toUpperCase());
const unassignedCategories = cats
.filter(cat => !knownCodes.includes(cat.code.toUpperCase()))
.sort((a, b) => a.display_order - b.display_order)
.map(cat => cat.code);
// Update "other" tab with unassigned categories
TAB_CONFIG.other.categories = unassignedCategories;
// Rebuild ASSIGNED_CATEGORIES
ASSIGNED_CATEGORIES = Object.values(TAB_CONFIG)
.flatMap(t => t.categories)
.map(c => c.toUpperCase());
} catch(e) {
console.error('Failed to load categories, using defaults', e);
// Will use default configuration if API fails
}
}
// Initialize
document.addEventListener('DOMContentLoaded', async function() {
// RBAC disabled - no token check required
if (!configUUID) {
window.location.href = '/configs';
return;
}
// Load categories first
await loadCategoriesFromAPI();
try {
const resp = await fetch('/api/configs/' + configUUID);
if (resp.status === 404) {
showToast('Конфигурация не найдена', 'error');
window.location.href = '/configs';
return;
}
const config = await resp.json();
configName = config.name;
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,
quantity: item.quantity,
unit_price: item.unit_price,
description: item.description || '',
category: item.category || getCategoryFromLotName(item.lot_name)
}));
}
// Restore custom price if saved
if (config.custom_price) {
document.getElementById('custom-price-input').value = config.custom_price.toFixed(2);
}
// Display price update date if available
if (config.price_updated_at) {
updatePriceUpdateDate(config.price_updated_at);
}
} catch(e) {
showToast('Ошибка загрузки конфигурации', 'error');
window.location.href = '/configs';
return;
}
await loadAllComponents();
renderTab();
updateCartUI();
// Close autocomplete on outside click
document.addEventListener('click', function(e) {
if (!e.target.closest('.autocomplete-wrapper')) {
hideAutocomplete();
}
});
});
async function loadAllComponents() {
try {
const resp = await fetch('/api/components?per_page=5000');
const data = await resp.json();
allComponents = data.components || [];
} catch(e) {
console.error('Failed to load components', e);
allComponents = [];
}
}
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] || '';
}
function getComponentCategory(comp) {
return (comp.category || getCategoryFromLotName(comp.lot_name)).toUpperCase();
}
function getTabForCategory(category) {
const cat = category.toUpperCase();
for (const [tabKey, tabConfig] of Object.entries(TAB_CONFIG)) {
if (tabConfig.categories.map(c => c.toUpperCase()).includes(cat)) {
return tabKey;
}
}
return 'other';
}
function switchTab(tab) {
currentTab = tab;
document.querySelectorAll('.tab-btn').forEach(btn => {
if (btn.dataset.tab === tab) {
btn.classList.remove('border-transparent', 'text-gray-500');
btn.classList.add('border-blue-600', 'text-blue-600');
} else {
btn.classList.add('border-transparent', 'text-gray-500');
btn.classList.remove('border-blue-600', 'text-blue-600');
}
});
hideAutocomplete();
renderTab();
}
function getComponentsForTab(tab) {
const config = TAB_CONFIG[tab];
return allComponents.filter(comp => {
const category = getComponentCategory(comp);
if (tab === 'other') {
return !ASSIGNED_CATEGORIES.includes(category);
}
return config.categories.map(c => c.toUpperCase()).includes(category);
});
}
function getComponentsForCategory(category) {
return allComponents.filter(comp => {
return getComponentCategory(comp) === category.toUpperCase();
});
}
function renderTab() {
const config = TAB_CONFIG[currentTab];
const components = getComponentsForTab(currentTab);
if (config.singleSelect) {
renderSingleSelectTab(config.categories);
} else if (config.sections) {
renderMultiSelectTabWithSections(config.sections);
} else {
renderMultiSelectTab(components);
}
}
function renderSingleSelectTab(categories) {
let html = `
<table class="w-full">
<thead class="bg-gray-50">
<tr>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase w-24">Тип</th>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">LOT</th>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Описание</th>
<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase w-24">Цена</th>
<th class="px-3 py-2 text-center text-xs font-medium text-gray-500 uppercase w-20">Кол-во</th>
<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase w-28">Стоимость</th>
<th class="px-3 py-2 w-10"></th>
</tr>
</thead>
<tbody class="divide-y">
`;
categories.forEach(cat => {
const catLabel = cat === 'MB' ? 'MB' : cat === 'CPU' ? 'CPU' : cat === 'MEM' ? 'MEM' : cat;
const selectedItem = cart.find(item =>
(item.category || getCategoryFromLotName(item.lot_name)).toUpperCase() === cat.toUpperCase()
);
const comp = selectedItem ? allComponents.find(c => c.lot_name === selectedItem.lot_name) : null;
const price = comp?.current_price || 0;
const qty = selectedItem?.quantity || 1;
const total = price * qty;
html += `
<tr class="hover:bg-gray-50">
<td class="px-3 py-2 text-sm font-medium text-gray-700">${catLabel}</td>
<td class="px-3 py-2">
<div class="autocomplete-wrapper relative">
<input type="text"
id="input-${cat}"
value="${selectedItem?.lot_name || ''}"
placeholder="Введите артикул..."
class="w-full px-2 py-1 border rounded text-sm font-mono"
onfocus="showAutocomplete('${cat}', this)"
oninput="filterAutocomplete('${cat}', this.value)"
onkeydown="handleAutocompleteKey(event, '${cat}')">
</div>
</td>
<td class="px-3 py-2 text-sm text-gray-500 truncate max-w-xs" id="desc-${cat}">${escapeHtml(comp?.description || '')}</td>
<td class="px-3 py-2 text-sm text-right" id="price-${cat}">${price ? '$' + price.toFixed(2) : '—'}</td>
<td class="px-3 py-2 text-center">
<input type="number" min="1" value="${qty}"
id="qty-${cat}"
onchange="updateSingleQuantity('${cat}', this.value)"
class="w-16 px-2 py-1 border rounded text-center text-sm">
</td>
<td class="px-3 py-2 text-sm text-right font-medium" id="total-${cat}">${total ? '$' + total.toFixed(2) : '—'}</td>
<td class="px-3 py-2 text-center">
${selectedItem ? `
<button onclick="clearSingleSelect('${cat}')" class="text-red-500 hover:text-red-700">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
` : ''}
</td>
</tr>
`;
});
html += '</tbody></table>';
document.getElementById('tab-content').innerHTML = html;
}
function renderMultiSelectTab(components) {
// Get cart items for this tab
const tabItems = cart.filter(item => {
const cat = (item.category || getCategoryFromLotName(item.lot_name)).toUpperCase();
const tab = getTabForCategory(cat);
return tab === currentTab;
});
let html = `
<table class="w-full">
<thead class="bg-gray-50">
<tr>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">LOT</th>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Описание</th>
<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase w-24">Цена</th>
<th class="px-3 py-2 text-center text-xs font-medium text-gray-500 uppercase w-20">Кол-во</th>
<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase w-28">Стоимость</th>
<th class="px-3 py-2 w-10"></th>
</tr>
</thead>
<tbody class="divide-y">
`;
// Render existing cart items for this tab
tabItems.forEach((item, idx) => {
const comp = allComponents.find(c => c.lot_name === item.lot_name);
const total = item.unit_price * item.quantity;
html += `
<tr class="hover:bg-gray-50">
<td class="px-3 py-2 text-sm font-mono">${escapeHtml(item.lot_name)}</td>
<td class="px-3 py-2 text-sm text-gray-500 truncate max-w-xs">${escapeHtml(item.description || comp?.description || '')}</td>
<td class="px-3 py-2 text-sm text-right">$${item.unit_price.toFixed(2)}</td>
<td class="px-3 py-2 text-center">
<input type="number" min="1" value="${item.quantity}"
onchange="updateMultiQuantity('${item.lot_name}', this.value)"
class="w-16 px-2 py-1 border rounded text-center text-sm">
</td>
<td class="px-3 py-2 text-sm text-right font-medium">$${total.toFixed(2)}</td>
<td class="px-3 py-2 text-center">
<button onclick="removeFromCart('${item.lot_name}')" class="text-red-500 hover:text-red-700">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</td>
</tr>
`;
});
// Add empty row for new item
html += `
<tr class="hover:bg-gray-50 bg-gray-50">
<td class="px-3 py-2" colspan="2">
<div class="autocomplete-wrapper relative">
<input type="text"
id="input-new-item"
placeholder="Добавить компонент..."
class="w-full px-2 py-1 border rounded text-sm"
onfocus="showAutocompleteMulti(this)"
oninput="filterAutocompleteMulti(this.value)"
onkeydown="handleAutocompleteKeyMulti(event)">
</div>
</td>
<td class="px-3 py-2 text-sm text-right text-gray-400" id="new-price">—</td>
<td class="px-3 py-2 text-center">
<input type="number" min="1" value="1" id="new-qty"
class="w-16 px-2 py-1 border rounded text-center text-sm">
</td>
<td class="px-3 py-2 text-sm text-right text-gray-400" id="new-total">—</td>
<td class="px-3 py-2"></td>
</tr>
`;
html += '</tbody></table>';
html += `<p class="text-center text-sm text-gray-500 mt-4">Доступно компонентов: ${components.length}</p>`;
document.getElementById('tab-content').innerHTML = html;
}
function renderMultiSelectTabWithSections(sections) {
// Get cart items for this tab
const tabItems = cart.filter(item => {
const cat = (item.category || getCategoryFromLotName(item.lot_name)).toUpperCase();
const tab = getTabForCategory(cat);
return tab === currentTab;
});
let html = '';
let totalComponents = 0;
sections.forEach((section, sectionIdx) => {
// Get components for this section's categories
const sectionCategories = section.categories.map(c => c.toUpperCase());
const sectionComponents = allComponents.filter(comp => {
const category = getComponentCategory(comp);
return sectionCategories.includes(category);
});
totalComponents += sectionComponents.length;
// Get cart items for this section
const sectionItems = tabItems.filter(item => {
const cat = (item.category || getCategoryFromLotName(item.lot_name)).toUpperCase();
return sectionCategories.includes(cat);
});
// Section header
html += `
<div class="mb-6 ${sectionIdx > 0 ? 'mt-6' : ''}">
<h3 class="text-sm font-semibold text-gray-700 mb-3 px-3">${section.title}</h3>
<table class="w-full">
<thead class="bg-gray-50">
<tr>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">LOT</th>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Описание</th>
<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase w-24">Цена</th>
<th class="px-3 py-2 text-center text-xs font-medium text-gray-500 uppercase w-20">Кол-во</th>
<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase w-28">Стоимость</th>
<th class="px-3 py-2 w-10"></th>
</tr>
</thead>
<tbody class="divide-y">
`;
// Render existing cart items for this section
sectionItems.forEach((item) => {
const comp = allComponents.find(c => c.lot_name === item.lot_name);
const total = item.unit_price * item.quantity;
html += `
<tr class="hover:bg-gray-50">
<td class="px-3 py-2 text-sm font-mono">${escapeHtml(item.lot_name)}</td>
<td class="px-3 py-2 text-sm text-gray-500 truncate max-w-xs">${escapeHtml(item.description || comp?.description || '')}</td>
<td class="px-3 py-2 text-sm text-right">$${item.unit_price.toFixed(2)}</td>
<td class="px-3 py-2 text-center">
<input type="number" min="1" value="${item.quantity}"
onchange="updateMultiQuantity('${item.lot_name}', this.value)"
class="w-16 px-2 py-1 border rounded text-center text-sm">
</td>
<td class="px-3 py-2 text-sm text-right font-medium">$${total.toFixed(2)}</td>
<td class="px-3 py-2 text-center">
<button onclick="removeFromCart('${item.lot_name}')" class="text-red-500 hover:text-red-700">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</td>
</tr>
`;
});
// Add empty row for new item in this section
const sectionId = section.categories.join('-');
const categoriesStr = section.categories.join(',');
html += `
<tr class="hover:bg-gray-50 bg-gray-50">
<td class="px-3 py-2" colspan="2">
<div class="autocomplete-wrapper relative">
<input type="text"
id="input-section-${sectionId}"
data-categories="${categoriesStr}"
placeholder="Добавить ${section.title.toLowerCase()}..."
class="w-full px-2 py-1 border rounded text-sm"
onfocus="showAutocompleteSection('${sectionId}', this)"
oninput="filterAutocompleteSection('${sectionId}', this.value, this)"
onkeydown="handleAutocompleteKeySection(event, '${sectionId}')">
</div>
</td>
<td class="px-3 py-2 text-sm text-right text-gray-400" id="new-price-${sectionId}">—</td>
<td class="px-3 py-2 text-center">
<input type="number" min="1" value="1" id="new-qty-${sectionId}"
class="w-16 px-2 py-1 border rounded text-center text-sm">
</td>
<td class="px-3 py-2 text-sm text-right text-gray-400" id="new-total-${sectionId}">—</td>
<td class="px-3 py-2"></td>
</tr>
`;
html += `
</tbody>
</table>
<p class="text-center text-sm text-gray-500 mt-2">Доступно: ${sectionComponents.length}</p>
</div>
`;
});
document.getElementById('tab-content').innerHTML = html;
}
// Autocomplete for single select (Base tab)
function showAutocomplete(category, input) {
autocompleteInput = input;
autocompleteCategory = category;
autocompleteMode = 'single';
autocompleteIndex = -1;
filterAutocomplete(category, input.value);
}
function filterAutocomplete(category, search) {
const components = getComponentsForCategory(category);
const searchLower = search.toLowerCase();
autocompleteFiltered = components.filter(c => {
if (!c.current_price) return false;
const text = (c.lot_name + ' ' + (c.description || '')).toLowerCase();
return text.includes(searchLower);
})
.sort((a, b) => {
// Sort by popularity_score desc, then by lot_name
const popDiff = (b.popularity_score || 0) - (a.popularity_score || 0);
if (popDiff !== 0) return popDiff;
return a.lot_name.localeCompare(b.lot_name);
});
renderAutocomplete();
}
function renderAutocomplete() {
const dropdown = document.getElementById('autocomplete-dropdown');
if (autocompleteFiltered.length === 0 || !autocompleteInput) {
dropdown.classList.add('hidden');
return;
}
const rect = autocompleteInput.getBoundingClientRect();
dropdown.style.top = (rect.bottom + window.scrollY) + 'px';
dropdown.style.left = rect.left + 'px';
dropdown.style.width = Math.max(rect.width, 400) + 'px';
// Build autocomplete items based on mode
dropdown.innerHTML = autocompleteFiltered.map((comp, idx) => {
let onmousedown;
if (autocompleteMode === 'section') {
onmousedown = `selectAutocompleteItemSection(${idx}, '${autocompleteCategory}')`;
} else if (autocompleteMode === 'multi') {
onmousedown = `selectAutocompleteItemMulti(${idx})`;
} else {
// single mode
onmousedown = `selectAutocompleteItem(${idx})`;
}
return `
<div class="autocomplete-item ${idx === autocompleteIndex ? 'selected' : ''}"
onmousedown="${onmousedown}">
<div class="font-mono text-sm">${escapeHtml(comp.lot_name)}</div>
<div class="text-xs text-gray-500 truncate">${escapeHtml(comp.description || '')}</div>
</div>
`;
}).join('');
dropdown.classList.remove('hidden');
}
function handleAutocompleteKey(event, category) {
if (event.key === 'ArrowDown') {
event.preventDefault();
autocompleteIndex = Math.min(autocompleteIndex + 1, autocompleteFiltered.length - 1);
renderAutocomplete();
} else if (event.key === 'ArrowUp') {
event.preventDefault();
autocompleteIndex = Math.max(autocompleteIndex - 1, -1);
renderAutocomplete();
} else if (event.key === 'Enter') {
event.preventDefault();
if (autocompleteIndex >= 0 && autocompleteIndex < autocompleteFiltered.length) {
selectAutocompleteItem(autocompleteIndex);
}
} else if (event.key === 'Escape') {
hideAutocomplete();
}
}
function selectAutocompleteItem(index) {
const comp = autocompleteFiltered[index];
if (!comp || !autocompleteCategory) return;
// Remove existing item of this category
cart = cart.filter(item =>
(item.category || getCategoryFromLotName(item.lot_name)).toUpperCase() !== autocompleteCategory.toUpperCase()
);
const qtyInput = document.getElementById('qty-' + autocompleteCategory);
const qty = parseInt(qtyInput?.value) || 1;
cart.push({
lot_name: comp.lot_name,
quantity: qty,
unit_price: comp.current_price,
description: comp.description || '',
category: getComponentCategory(comp)
});
hideAutocomplete();
renderTab();
updateCartUI();
triggerAutoSave();
}
function hideAutocomplete() {
document.getElementById('autocomplete-dropdown').classList.add('hidden');
autocompleteInput = null;
autocompleteCategory = null;
autocompleteMode = null;
autocompleteIndex = -1;
}
// Autocomplete for multi select tabs
function showAutocompleteMulti(input) {
autocompleteInput = input;
autocompleteCategory = null;
autocompleteMode = 'multi';
autocompleteIndex = -1;
filterAutocompleteMulti(input.value);
}
function filterAutocompleteMulti(search) {
const components = getComponentsForTab(currentTab);
const searchLower = search.toLowerCase();
// Filter out already added items
const addedLots = new Set(cart.map(i => i.lot_name));
autocompleteFiltered = components.filter(c => {
if (!c.current_price) return false;
if (addedLots.has(c.lot_name)) return false;
const text = (c.lot_name + ' ' + (c.description || '')).toLowerCase();
return text.includes(searchLower);
})
.sort((a, b) => {
// Sort by popularity_score desc, then by lot_name
const popDiff = (b.popularity_score || 0) - (a.popularity_score || 0);
if (popDiff !== 0) return popDiff;
return a.lot_name.localeCompare(b.lot_name);
});
renderAutocomplete();
}
function handleAutocompleteKeyMulti(event) {
if (event.key === 'ArrowDown') {
event.preventDefault();
autocompleteIndex = Math.min(autocompleteIndex + 1, autocompleteFiltered.length - 1);
renderAutocomplete();
} else if (event.key === 'ArrowUp') {
event.preventDefault();
autocompleteIndex = Math.max(autocompleteIndex - 1, -1);
renderAutocomplete();
} else if (event.key === 'Enter') {
event.preventDefault();
if (autocompleteIndex >= 0 && autocompleteIndex < autocompleteFiltered.length) {
selectAutocompleteItemMulti(autocompleteIndex);
}
} else if (event.key === 'Escape') {
hideAutocomplete();
}
}
function selectAutocompleteItemMulti(index) {
const comp = autocompleteFiltered[index];
if (!comp) return;
const qtyInput = document.getElementById('new-qty');
const qty = parseInt(qtyInput?.value) || 1;
cart.push({
lot_name: comp.lot_name,
quantity: qty,
unit_price: comp.current_price,
description: comp.description || '',
category: getComponentCategory(comp)
});
hideAutocomplete();
renderTab();
updateCartUI();
triggerAutoSave();
}
// Autocomplete for sectioned tabs (like storage with RAID and Disks sections)
function showAutocompleteSection(sectionId, input) {
autocompleteInput = input;
autocompleteCategory = sectionId; // Store section ID
autocompleteMode = 'section';
autocompleteIndex = -1;
filterAutocompleteSection(sectionId, input.value, input);
}
function filterAutocompleteSection(sectionId, search, inputElement) {
const searchLower = search.toLowerCase();
// Get categories from input element's data attribute
const categoriesStr = inputElement && inputElement.dataset ? inputElement.dataset.categories : '';
if (!categoriesStr) {
autocompleteFiltered = [];
renderAutocomplete();
return;
}
const categoryList = categoriesStr.split(',').map(c => c.trim().toUpperCase());
// Get components for this section's categories
const sectionComponents = allComponents.filter(comp => {
const category = getComponentCategory(comp);
return categoryList.includes(category);
});
// Filter out already added items
const addedLots = new Set(cart.map(i => i.lot_name));
autocompleteFiltered = sectionComponents.filter(c => {
if (!c.current_price) return false;
if (addedLots.has(c.lot_name)) return false;
const text = (c.lot_name + ' ' + (c.description || '')).toLowerCase();
return text.includes(searchLower);
})
.sort((a, b) => {
// Sort by popularity_score desc, then by lot_name
const popDiff = (b.popularity_score || 0) - (a.popularity_score || 0);
if (popDiff !== 0) return popDiff;
return a.lot_name.localeCompare(b.lot_name);
});
renderAutocomplete();
}
function handleAutocompleteKeySection(event, sectionId) {
if (event.key === 'ArrowDown') {
event.preventDefault();
autocompleteIndex = Math.min(autocompleteIndex + 1, autocompleteFiltered.length - 1);
renderAutocomplete();
} else if (event.key === 'ArrowUp') {
event.preventDefault();
autocompleteIndex = Math.max(autocompleteIndex - 1, -1);
renderAutocomplete();
} else if (event.key === 'Enter') {
event.preventDefault();
if (autocompleteIndex >= 0 && autocompleteIndex < autocompleteFiltered.length) {
selectAutocompleteItemSection(autocompleteIndex, sectionId);
}
} else if (event.key === 'Escape') {
hideAutocomplete();
}
}
function selectAutocompleteItemSection(index, sectionId) {
const comp = autocompleteFiltered[index];
if (!comp) return;
const qtyInput = document.getElementById('new-qty-' + sectionId);
const qty = parseInt(qtyInput?.value) || 1;
cart.push({
lot_name: comp.lot_name,
quantity: qty,
unit_price: comp.current_price,
description: comp.description || '',
category: getComponentCategory(comp)
});
hideAutocomplete();
// Clear the input field
const input = document.getElementById('input-section-' + sectionId);
if (input) input.value = '';
// Reset quantity to 1
if (qtyInput) qtyInput.value = '1';
renderTab();
updateCartUI();
triggerAutoSave();
}
function clearSingleSelect(category) {
cart = cart.filter(item =>
(item.category || getCategoryFromLotName(item.lot_name)).toUpperCase() !== category.toUpperCase()
);
renderTab();
updateCartUI();
triggerAutoSave();
}
function updateSingleQuantity(category, value) {
const qty = parseInt(value) || 1;
const item = cart.find(i =>
(i.category || getCategoryFromLotName(i.lot_name)).toUpperCase() === category.toUpperCase()
);
if (item) {
item.quantity = Math.max(1, qty);
renderTab();
updateCartUI();
triggerAutoSave();
}
}
function updateMultiQuantity(lotName, value) {
const qty = parseInt(value) || 1;
const item = cart.find(i => i.lot_name === lotName);
if (item) {
item.quantity = Math.max(1, qty);
updateCartUI();
triggerAutoSave();
// Update total in the row
const row = document.querySelector(`input[onchange*="${lotName}"]`)?.closest('tr');
if (row) {
const totalCell = row.querySelector('td:nth-child(5)');
if (totalCell) {
totalCell.textContent = '$' + (item.unit_price * item.quantity).toFixed(2);
}
}
}
}
function removeFromCart(lotName) {
cart = cart.filter(i => i.lot_name !== lotName);
renderTab();
updateCartUI();
triggerAutoSave();
}
function updateCartUI() {
const total = cart.reduce((sum, item) => sum + (item.unit_price * item.quantity), 0);
document.getElementById('cart-total').textContent = '$' + total.toLocaleString('en-US', {minimumFractionDigits: 2});
// Recalculate custom price section if active
calculateCustomPrice();
if (cart.length === 0) {
document.getElementById('cart-items').innerHTML =
'<div class="text-gray-500 text-center py-2">Конфигурация пуста</div>';
return;
}
// Sort cart items by category display order
const sortedCart = [...cart].sort((a, b) => {
const catA = (a.category || getCategoryFromLotName(a.lot_name)).toUpperCase();
const catB = (b.category || getCategoryFromLotName(b.lot_name)).toUpperCase();
const orderA = categoryOrderMap[catA] || 9999;
const orderB = categoryOrderMap[catB] || 9999;
return orderA - orderB;
});
const grouped = {};
sortedCart.forEach(item => {
const cat = item.category || getCategoryFromLotName(item.lot_name);
const tab = getTabForCategory(cat);
if (!grouped[tab]) grouped[tab] = [];
grouped[tab].push(item);
});
// Sort tabs by minimum display order of their categories
const sortedTabs = Object.entries(grouped).sort((a, b) => {
const minOrderA = Math.min(...a[1].map(item => {
const cat = (item.category || getCategoryFromLotName(item.lot_name)).toUpperCase();
return categoryOrderMap[cat] || 9999;
}));
const minOrderB = Math.min(...b[1].map(item => {
const cat = (item.category || getCategoryFromLotName(item.lot_name)).toUpperCase();
return categoryOrderMap[cat] || 9999;
}));
return minOrderA - minOrderB;
});
let html = '';
for (const [tab, items] of sortedTabs) {
const tabLabel = TAB_CONFIG[tab]?.label || tab;
html += `<div class="mb-2"><div class="text-xs font-medium text-gray-400 uppercase mb-1">${tabLabel}</div>`;
items.forEach(item => {
const itemTotal = item.unit_price * item.quantity;
html += `
<div class="flex justify-between items-center py-1 text-sm">
<div class="flex-1">
<span class="font-mono">${escapeHtml(item.lot_name)}</span>
<span class="text-gray-400 mx-1">×</span>
<span>${item.quantity}</span>
</div>
<div class="flex items-center space-x-2">
<span>$${itemTotal.toLocaleString('en-US', {minimumFractionDigits: 2})}</span>
<button onclick="removeFromCart('${item.lot_name}')" class="text-red-500 hover:text-red-700">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
</div>
`;
});
html += '</div>';
}
document.getElementById('cart-items').innerHTML = html;
}
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function triggerAutoSave() {
// Debounce autosave - wait 1 second after last change
if (autoSaveTimeout) {
clearTimeout(autoSaveTimeout);
}
autoSaveTimeout = setTimeout(() => {
saveConfig(false); // false = don't show notification
}, 1000);
}
async function saveConfig(showNotification = true) {
// RBAC disabled - no token check required
if (!configUUID) return;
// Get custom price if set
const customPriceInput = document.getElementById('custom-price-input');
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',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
name: configName,
items: cart,
custom_price: customPrice,
notes: '',
server_count: serverCountValue
})
});
if (!resp.ok) {
if (showNotification) {
showToast('Ошибка сохранения', 'error');
}
return;
}
if (showNotification) {
showToast('Сохранено', 'success');
}
} catch(e) {
if (showNotification) {
showToast('Ошибка сохранения', 'error');
}
}
}
async function exportCSV() {
if (cart.length === 0) return;
try {
const resp = await fetch('/api/export/csv', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({items: cart, name: configName})
});
const blob = await resp.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = (configName || 'config') + '.csv';
a.click();
window.URL.revokeObjectURL(url);
} catch(e) {
showToast('Ошибка экспорта', 'error');
}
}
// Custom price functionality
function calculateCustomPrice() {
const customPriceInput = document.getElementById('custom-price-input');
const customPrice = parseFloat(customPriceInput.value) || 0;
const originalTotal = cart.reduce((sum, item) => sum + (item.unit_price * item.quantity), 0);
if (customPrice <= 0 || cart.length === 0 || originalTotal <= 0) {
document.getElementById('adjusted-prices').classList.add('hidden');
document.getElementById('discount-info').classList.add('hidden');
return;
}
// Calculate discount percentage
const discountPercent = ((originalTotal - customPrice) / originalTotal) * 100;
const coefficient = customPrice / originalTotal;
// Show discount info
document.getElementById('discount-info').classList.remove('hidden');
document.getElementById('discount-percent').textContent = discountPercent.toFixed(1) + '%';
// Update discount color based on value
const discountEl = document.getElementById('discount-percent');
if (discountPercent > 0) {
discountEl.className = 'text-2xl font-bold text-green-600';
} else if (discountPercent < 0) {
discountEl.className = 'text-2xl font-bold text-red-600';
} else {
discountEl.className = 'text-2xl font-bold text-gray-600';
}
// Build adjusted prices table
// Sort cart items by category display order
const sortedCart = [...cart].sort((a, b) => {
const catA = (a.category || getCategoryFromLotName(a.lot_name)).toUpperCase();
const catB = (b.category || getCategoryFromLotName(b.lot_name)).toUpperCase();
const orderA = categoryOrderMap[catA] || 9999;
const orderB = categoryOrderMap[catB] || 9999;
return orderA - orderB;
});
let html = '';
let totalOriginal = 0;
let totalNew = 0;
sortedCart.forEach(item => {
const originalPrice = item.unit_price;
const newPrice = originalPrice * coefficient;
const itemOriginalTotal = originalPrice * item.quantity;
const itemNewTotal = newPrice * item.quantity;
totalOriginal += itemOriginalTotal;
totalNew += itemNewTotal;
html += `
<tr>
<td class="px-3 py-2 font-mono">${escapeHtml(item.lot_name)}</td>
<td class="px-3 py-2 text-right">${item.quantity}</td>
<td class="px-3 py-2 text-right text-gray-500">$${originalPrice.toFixed(2)}</td>
<td class="px-3 py-2 text-right text-green-600">$${newPrice.toFixed(2)}</td>
<td class="px-3 py-2 text-right">$${itemNewTotal.toFixed(2)}</td>
</tr>
`;
});
document.getElementById('adjusted-prices-body').innerHTML = html;
document.getElementById('adjusted-total-original').textContent = '$' + totalOriginal.toFixed(2);
document.getElementById('adjusted-total-new').textContent = '$' + totalNew.toFixed(2);
document.getElementById('adjusted-total-final').textContent = '$' + totalNew.toFixed(2);
document.getElementById('adjusted-prices').classList.remove('hidden');
}
function clearCustomPrice() {
document.getElementById('custom-price-input').value = '';
document.getElementById('adjusted-prices').classList.add('hidden');
document.getElementById('discount-info').classList.add('hidden');
triggerAutoSave();
}
async function exportCSVWithCustomPrice() {
if (cart.length === 0) return;
const customPrice = parseFloat(document.getElementById('custom-price-input').value) || 0;
const originalTotal = cart.reduce((sum, item) => sum + (item.unit_price * item.quantity), 0);
if (customPrice <= 0 || originalTotal <= 0) {
showToast('Введите целевую цену', 'error');
return;
}
const coefficient = customPrice / originalTotal;
// Create adjusted cart
const adjustedCart = cart.map(item => ({
...item,
unit_price: parseFloat((item.unit_price * coefficient).toFixed(2))
}));
try {
const resp = await fetch('/api/export/csv', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({items: adjustedCart, name: configName})
});
const blob = await resp.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = (configName || 'config') + '.csv';
a.click();
window.URL.revokeObjectURL(url);
} catch(e) {
showToast('Ошибка экспорта', 'error');
}
}
async function refreshPrices() {
// RBAC disabled - no token check required
if (!configUUID) return;
try {
const resp = await fetch('/api/configs/' + configUUID + '/refresh-prices', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
});
if (!resp.ok) {
showToast('Ошибка обновления цен', 'error');
return;
}
const config = await resp.json();
// Update cart with new prices
if (config.items && config.items.length > 0) {
cart = config.items.map(item => ({
lot_name: item.lot_name,
quantity: item.quantity,
unit_price: item.unit_price,
description: item.description || '',
category: item.category || getCategoryFromLotName(item.lot_name)
}));
}
// Update price update date
if (config.price_updated_at) {
updatePriceUpdateDate(config.price_updated_at);
}
// Re-render UI
renderTab();
updateCartUI();
showToast('Цены обновлены', 'success');
} catch(e) {
showToast('Ошибка обновления цен', 'error');
}
}
function updatePriceUpdateDate(dateStr) {
if (!dateStr) {
document.getElementById('price-update-date').textContent = '';
return;
}
const date = new Date(dateStr);
const now = new Date();
const diffMs = now - date;
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
let timeAgo;
if (diffMins < 1) {
timeAgo = 'только что';
} else if (diffMins < 60) {
timeAgo = diffMins + ' мин. назад';
} else if (diffHours < 24) {
timeAgo = diffHours + ' ч. назад';
} else if (diffDays < 7) {
timeAgo = diffDays + ' дн. назад';
} else {
timeAgo = date.toLocaleDateString('ru-RU');
}
document.getElementById('price-update-date').textContent = 'Обновлено: ' + timeAgo;
}
</script>
{{end}}
{{template "base" .}}