759 lines
28 KiB
HTML
759 lines
28 KiB
HTML
{{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 space-x-2">
|
||
<button onclick="saveConfig()" class="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700">
|
||
Сохранить
|
||
</button>
|
||
</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>
|
||
</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
|
||
const TAB_CONFIG = {
|
||
base: {
|
||
categories: ['MB', 'CPU', 'MEM'],
|
||
singleSelect: true,
|
||
label: 'Base'
|
||
},
|
||
storage: {
|
||
categories: ['M2', 'SSD', 'HDD', 'EDSFF', 'HHHL'],
|
||
singleSelect: false,
|
||
label: 'Storage'
|
||
},
|
||
pci: {
|
||
categories: ['HBA', 'HCA', 'NIC', 'GPU', 'RAID', 'DPU'],
|
||
singleSelect: false,
|
||
label: 'PCI'
|
||
},
|
||
power: {
|
||
categories: ['PS', 'PSU'],
|
||
singleSelect: false,
|
||
label: 'Power'
|
||
},
|
||
accessories: {
|
||
categories: ['ACC', 'CARD'],
|
||
singleSelect: false,
|
||
label: 'Accessories'
|
||
},
|
||
other: {
|
||
categories: [],
|
||
singleSelect: false,
|
||
label: 'Other'
|
||
}
|
||
};
|
||
|
||
const 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 = [];
|
||
|
||
// Autocomplete state
|
||
let autocompleteInput = null;
|
||
let autocompleteCategory = null;
|
||
let autocompleteIndex = -1;
|
||
let autocompleteFiltered = [];
|
||
|
||
// Initialize
|
||
document.addEventListener('DOMContentLoaded', async function() {
|
||
const token = localStorage.getItem('token');
|
||
|
||
if (!token || !configUUID) {
|
||
window.location.href = '/configs';
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const resp = await fetch('/api/configs/' + configUUID, {
|
||
headers: {'Authorization': 'Bearer ' + token}
|
||
});
|
||
|
||
if (resp.status === 401) {
|
||
window.location.href = '/login';
|
||
return;
|
||
}
|
||
|
||
if (resp.status === 403 || 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');
|
||
|
||
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)
|
||
}));
|
||
}
|
||
} 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 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 {
|
||
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;
|
||
}
|
||
|
||
// Autocomplete for single select (Base tab)
|
||
function showAutocomplete(category, input) {
|
||
autocompleteInput = input;
|
||
autocompleteCategory = category;
|
||
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';
|
||
|
||
// Use different select function based on mode (single vs multi)
|
||
const selectFn = autocompleteCategory ? 'selectAutocompleteItem' : 'selectAutocompleteItemMulti';
|
||
|
||
dropdown.innerHTML = autocompleteFiltered.map((comp, idx) => `
|
||
<div class="autocomplete-item ${idx === autocompleteIndex ? 'selected' : ''}"
|
||
onmousedown="${selectFn}(${idx})">
|
||
<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();
|
||
}
|
||
|
||
function hideAutocomplete() {
|
||
document.getElementById('autocomplete-dropdown').classList.add('hidden');
|
||
autocompleteInput = null;
|
||
autocompleteCategory = null;
|
||
autocompleteIndex = -1;
|
||
}
|
||
|
||
// Autocomplete for multi select tabs
|
||
function showAutocompleteMulti(input) {
|
||
autocompleteInput = input;
|
||
autocompleteCategory = null;
|
||
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();
|
||
}
|
||
|
||
function clearSingleSelect(category) {
|
||
cart = cart.filter(item =>
|
||
(item.category || getCategoryFromLotName(item.lot_name)).toUpperCase() !== category.toUpperCase()
|
||
);
|
||
renderTab();
|
||
updateCartUI();
|
||
}
|
||
|
||
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();
|
||
}
|
||
}
|
||
|
||
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();
|
||
// 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();
|
||
}
|
||
|
||
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});
|
||
|
||
if (cart.length === 0) {
|
||
document.getElementById('cart-items').innerHTML =
|
||
'<div class="text-gray-500 text-center py-2">Конфигурация пуста</div>';
|
||
return;
|
||
}
|
||
|
||
const grouped = {};
|
||
cart.forEach(item => {
|
||
const cat = item.category || getCategoryFromLotName(item.lot_name);
|
||
const tab = getTabForCategory(cat);
|
||
if (!grouped[tab]) grouped[tab] = [];
|
||
grouped[tab].push(item);
|
||
});
|
||
|
||
let html = '';
|
||
for (const [tab, items] of Object.entries(grouped)) {
|
||
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;
|
||
}
|
||
|
||
async function saveConfig() {
|
||
const token = localStorage.getItem('token');
|
||
if (!token || !configUUID) return;
|
||
|
||
try {
|
||
const resp = await fetch('/api/configs/' + configUUID, {
|
||
method: 'PUT',
|
||
headers: {
|
||
'Authorization': 'Bearer ' + token,
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify({
|
||
name: configName,
|
||
items: cart,
|
||
notes: ''
|
||
})
|
||
});
|
||
|
||
if (resp.status === 401) {
|
||
window.location.href = '/login';
|
||
return;
|
||
}
|
||
|
||
if (!resp.ok) {
|
||
showToast('Ошибка сохранения', 'error');
|
||
return;
|
||
}
|
||
|
||
showToast('Сохранено', 'success');
|
||
} catch(e) {
|
||
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');
|
||
}
|
||
}
|
||
|
||
</script>
|
||
{{end}}
|
||
|
||
{{template "base" .}}
|