Redesign configurator UI with tabs and remove Excel export

- Add tab-based configurator (Base, Storage, PCI, Power, Accessories, Other)
- Base tab: single-select with autocomplete for MB, CPU, MEM
- Other tabs: multi-select with autocomplete and quantity input
- Table view with LOT, Description, Price, Quantity, Total columns
- Add configuration list page with create modal (opportunity number)
- Remove Excel export functionality and excelize dependency
- Increase component list limit from 100 to 5000
- Add web templates (base, index, configs, login, admin_pricing)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Mikhail Chusavitin
2026-01-26 15:57:15 +03:00
co-authored by Claude Opus 4.5
parent 44ccb01203
commit a93644131c
14 changed files with 2041 additions and 201 deletions
+549
View File
@@ -0,0 +1,549 @@
{{define "title"}}Цены - QuoteForge{{end}}
{{define "content"}}
<div class="space-y-4">
<h1 class="text-2xl font-bold">Управление ценами</h1>
<div class="bg-white rounded-lg shadow p-4">
<div class="flex justify-between items-center border-b pb-4 mb-4">
<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>
</div>
<button onclick="recalculateAll()" id="btn-recalc" class="px-3 py-1 bg-green-600 text-white text-sm rounded hover:bg-green-700">
Пересчитать цены
</button>
</div>
<!-- Progress bar -->
<div id="progress-container" class="mb-4 p-4 bg-blue-50 rounded-lg border border-blue-200" style="display:none;">
<div class="flex justify-between text-sm text-gray-700 mb-2">
<span id="progress-text" class="font-medium">Пересчёт цен...</span>
<span id="progress-percent" class="font-bold">0%</span>
</div>
<div class="w-full bg-gray-200 rounded-full h-4">
<div id="progress-bar" class="bg-blue-600 h-4 rounded-full transition-all duration-300" style="width: 0%"></div>
</div>
<div class="text-sm text-gray-600 mt-2">
<span id="progress-stats"></span>
</div>
</div>
<!-- Search (only for components) -->
<div id="search-bar" class="mb-4 hidden">
<input type="text" id="search-input" placeholder="Поиск по артикулу..."
class="w-full px-3 py-2 border rounded"
onkeyup="debounceSearch()">
</div>
<div id="tab-content">
<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>
</div>
<!-- Price Settings Modal -->
<div id="price-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
<div class="bg-white rounded-lg shadow-xl w-full max-w-md mx-4">
<div class="flex justify-between items-center p-4 border-b">
<h3 class="text-lg font-semibold">Настройка цены</h3>
<button onclick="closeModal()" class="text-gray-500 hover:text-gray-700">&times;</button>
</div>
<div class="p-4 space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Артикул</label>
<input type="text" id="modal-lot-name" readonly class="w-full px-3 py-2 border rounded bg-gray-100">
</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">
<option value="median">Медиана</option>
<option value="average">Среднее</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Период расчёта</label>
<select id="modal-period" class="w-full px-3 py-2 border rounded">
<option value="7">1 неделя</option>
<option value="30">1 месяц</option>
<option value="90">1 квартал</option>
<option value="365">1 год</option>
<option value="0">Всё время</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Коэффициент корректировки (%)</label>
<input type="number" id="modal-coefficient" step="1" class="w-full px-3 py-2 border rounded" placeholder="0">
<p class="text-xs text-gray-500 mt-1">Например: 30 для +30%, -10 для -10%</p>
</div>
<div class="border-t pt-4">
<label class="flex items-center mb-2">
<input type="checkbox" id="modal-manual-enabled" class="mr-2" onchange="toggleManualPrice()">
<span class="text-sm font-medium text-gray-700">Установить цену вручную</span>
</label>
<input type="number" id="modal-manual-price" step="0.01" class="w-full px-3 py-2 border rounded" placeholder="Цена USD" disabled>
<p class="text-xs text-gray-500 mt-1">Ручная цена сохраняется при пересчёте</p>
</div>
<div class="bg-gray-50 p-3 rounded space-y-2">
<div class="text-sm font-medium text-gray-700 mb-2">Расчёт цены</div>
<div class="grid grid-cols-2 gap-2 text-sm">
<div class="text-gray-600">Медиана (всё время):</div>
<div id="modal-median-all" class="font-medium text-right"></div>
<div class="text-gray-600">Текущая цена:</div>
<div id="modal-current-price" class="font-medium text-right"></div>
<div class="text-gray-600 font-medium text-blue-600">Новая цена:</div>
<div id="modal-new-price" class="font-bold text-right text-blue-600"></div>
</div>
<div class="text-xs text-gray-500 pt-2 border-t">
Кол-во котировок: <span id="modal-quote-count"></span>
</div>
</div>
</div>
<div class="flex justify-end gap-2 p-4 border-t">
<button onclick="closeModal()" class="px-4 py-2 border rounded hover:bg-gray-50">Отмена</button>
<button onclick="savePrice()" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">Сохранить</button>
</div>
</div>
</div>
<script>
let currentTab = 'alerts';
let currentPage = 1;
let totalPages = 1;
let perPage = 50;
let searchTimeout = null;
let currentSearch = '';
let componentsCache = [];
async function loadTab(tab) {
currentTab = tab;
currentPage = 1;
currentSearch = '';
document.getElementById('search-input').value = '';
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';
await loadData();
}
async function loadData() {
const token = localStorage.getItem('token');
if (!token) {
window.location.href = '/login';
return;
}
document.getElementById('tab-content').innerHTML = '<div class="text-center py-8 text-gray-500">Загрузка...</div>';
try {
if (currentTab === 'alerts') {
const resp = await fetch('/admin/pricing/alerts?per_page=100', {
headers: {'Authorization': 'Bearer ' + token}
});
if (resp.status === 401) { logout(); return; }
if (resp.status === 403) { window.location.href = '/'; return; }
const data = await resp.json();
renderAlerts(data.alerts || []);
} else {
let url = '/admin/pricing/components?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; }
const data = await resp.json();
totalPages = Math.ceil(data.total / perPage);
componentsCache = data.components || [];
renderComponents(componentsCache, data.total);
updatePagination(data.total);
}
} catch(e) {
document.getElementById('tab-content').innerHTML = '<div class="text-center py-8 text-red-600">Ошибка загрузки</div>';
}
}
function debounceSearch() {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
currentSearch = document.getElementById('search-input').value;
currentPage = 1;
loadData();
}, 300);
}
function prevPage() {
if (currentPage > 1) {
currentPage--;
loadData();
}
}
function nextPage() {
if (currentPage < totalPages) {
currentPage++;
loadData();
}
}
function updatePagination(total) {
document.getElementById('page-info').textContent =
'Страница ' + currentPage + ' из ' + totalPages + ' (всего: ' + total + ')';
document.getElementById('btn-prev').disabled = currentPage <= 1;
document.getElementById('btn-next').disabled = currentPage >= totalPages;
}
function renderAlerts(alerts) {
if (alerts.length === 0) {
document.getElementById('tab-content').innerHTML = '<div class="text-center py-8 text-green-600">Нет активных алертов</div>';
return;
}
let html = '<div class="space-y-2">';
alerts.forEach(a => {
const colors = {critical: 'bg-red-100', high: 'bg-orange-100', medium: 'bg-yellow-100', low: 'bg-blue-100'};
html += '<div class="' + (colors[a.severity] || 'bg-gray-100') + ' p-3 rounded">';
html += '<div class="flex justify-between"><span class="font-medium">' + escapeHtml(a.lot_name) + '</span>';
html += '<span class="text-xs uppercase">' + a.severity + '</span></div>';
html += '<p class="text-sm text-gray-600">' + escapeHtml(a.message) + '</p></div>';
});
html += '</div>';
document.getElementById('tab-content').innerHTML = html;
}
function renderComponents(components, total) {
if (components.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-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 += '<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Настройки</th>';
html += '</tr></thead><tbody class="divide-y">';
components.forEach((c, idx) => {
const price = c.current_price ? '$' + parseFloat(c.current_price).toLocaleString('en-US', {minimumFractionDigits: 2}) : '—';
const category = c.category ? c.category.code : '—';
const desc = c.lot && c.lot.lot_description ? c.lot.lot_description : '—';
const quoteCount = c.quote_count || 0;
// Build settings summary
let settings = [];
const method = c.price_method || 'median';
settings.push(method === 'median' ? 'М' : 'С');
const period = c.price_period_days !== undefined && c.price_period_days !== null ? c.price_period_days : 90;
if (period === 7) settings.push('1н');
else if (period === 30) settings.push('1м');
else if (period === 90) settings.push('3м');
else if (period === 365) settings.push('1г');
else if (period === 0) settings.push('все');
else settings.push(period + 'д');
if (c.price_coefficient && c.price_coefficient !== 0) {
settings.push((c.price_coefficient > 0 ? '+' : '') + c.price_coefficient + '%');
}
if (c.manual_price && c.manual_price > 0) {
settings.push('РУЧН');
}
html += '<tr class="hover:bg-gray-50 cursor-pointer" onclick="openModal(' + idx + ')">';
html += '<td class="px-3 py-2 text-sm font-mono">' + escapeHtml(c.lot_name) + '</td>';
html += '<td class="px-3 py-2 text-sm">' + escapeHtml(category) + '</td>';
html += '<td class="px-3 py-2 text-sm text-gray-500 max-w-xs truncate">' + escapeHtml(desc) + '</td>';
html += '<td class="px-3 py-2 text-sm text-right">' + quoteCount + '</td>';
html += '<td class="px-3 py-2 text-sm text-right font-medium">' + price + '</td>';
html += '<td class="px-3 py-2 text-sm"><span class="text-xs bg-gray-100 px-2 py-1 rounded">' + settings.join(' | ') + '</span></td>';
html += '</tr>';
});
html += '</tbody></table></div>';
document.getElementById('tab-content').innerHTML = html;
}
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Modal functions
function openModal(idx) {
const c = componentsCache[idx];
if (!c) return;
document.getElementById('modal-lot-name').value = c.lot_name;
document.getElementById('modal-method').value = c.price_method || 'median';
document.getElementById('modal-period').value = String(c.price_period_days !== undefined && c.price_period_days !== null ? c.price_period_days : 90);
document.getElementById('modal-coefficient').value = c.price_coefficient || 0;
const hasManual = c.manual_price && c.manual_price > 0;
document.getElementById('modal-manual-enabled').checked = hasManual;
document.getElementById('modal-manual-price').value = hasManual ? c.manual_price : '';
document.getElementById('modal-manual-price').disabled = !hasManual;
// Reset price displays while loading
document.getElementById('modal-median-all').textContent = '...';
document.getElementById('modal-current-price').textContent = '...';
document.getElementById('modal-new-price').textContent = '...';
document.getElementById('modal-quote-count').textContent = '...';
document.getElementById('price-modal').classList.remove('hidden');
document.getElementById('price-modal').classList.add('flex');
// Fetch price preview
fetchPreview();
}
async function fetchPreview() {
const token = localStorage.getItem('token');
if (!token) return;
const lotName = document.getElementById('modal-lot-name').value;
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;
try {
const resp = await fetch('/admin/pricing/preview', {
method: 'POST',
headers: {
'Authorization': 'Bearer ' + token,
'Content-Type': 'application/json'
},
body: JSON.stringify({
lot_name: lotName,
method: method,
period_days: periodDays,
coefficient: coefficient
})
});
if (resp.status === 401) { logout(); return; }
if (resp.ok) {
const data = await resp.json();
// Update median all time
document.getElementById('modal-median-all').textContent =
data.median_all_time ? '$' + parseFloat(data.median_all_time).toFixed(2) : '—';
// Update current price
document.getElementById('modal-current-price').textContent =
data.current_price ? '$' + parseFloat(data.current_price).toFixed(2) : '—';
// Update new calculated price
document.getElementById('modal-new-price').textContent =
data.new_price ? '$' + parseFloat(data.new_price).toFixed(2) : '—';
// Update quote count
document.getElementById('modal-quote-count').textContent = data.quote_count || 0;
}
} catch(e) {
console.error('Preview fetch error:', e);
document.getElementById('modal-median-all').textContent = '—';
document.getElementById('modal-new-price').textContent = '—';
}
}
function closeModal() {
document.getElementById('price-modal').classList.add('hidden');
document.getElementById('price-modal').classList.remove('flex');
}
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 = '';
}
fetchPreview();
}
// Debounce helper for preview updates
let previewTimeout = null;
function debounceFetchPreview() {
clearTimeout(previewTimeout);
previewTimeout = setTimeout(fetchPreview, 300);
}
async function savePrice() {
const token = localStorage.getItem('token');
if (!token) {
window.location.href = '/login';
return;
}
const lotName = document.getElementById('modal-lot-name').value;
const method = document.getElementById('modal-method').value;
const periodDaysStr = document.getElementById('modal-period').value;
const periodDays = periodDaysStr !== '' ? parseInt(periodDaysStr) : 90;
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 body = {
lot_name: lotName,
method: method,
period_days: periodDays,
coefficient: coefficient,
clear_manual: !manualEnabled
};
if (manualEnabled && manualPrice > 0) {
body.manual_price = manualPrice;
}
try {
const resp = await fetch('/admin/pricing/update', {
method: 'POST',
headers: {
'Authorization': 'Bearer ' + token,
'Content-Type': 'application/json'
},
body: JSON.stringify(body)
});
if (resp.status === 401) { logout(); return; }
if (resp.ok) {
closeModal();
loadData();
} else {
const data = await resp.json();
alert('Ошибка: ' + (data.error || 'Неизвестная ошибка'));
}
} catch(e) {
alert('Ошибка соединения');
}
}
function recalculateAll() {
const token = localStorage.getItem('token');
if (!token) {
window.location.href = '/login';
return;
}
const btn = document.getElementById('btn-recalc');
const progressContainer = document.getElementById('progress-container');
const progressBar = document.getElementById('progress-bar');
const progressText = document.getElementById('progress-text');
const progressPercent = document.getElementById('progress-percent');
const progressStats = document.getElementById('progress-stats');
// Show progress bar IMMEDIATELY
btn.disabled = true;
btn.textContent = 'Пересчёт...';
progressContainer.style.display = 'block';
progressBar.style.width = '0%';
progressBar.className = 'bg-blue-600 h-4 rounded-full transition-all duration-300';
progressText.textContent = 'Запуск пересчёта...';
progressPercent.textContent = '0%';
progressStats.textContent = 'Подготовка...';
// Use fetch with streaming for SSE
fetch('/admin/pricing/recalculate-all', {
method: 'POST',
headers: {'Authorization': 'Bearer ' + token}
}).then(response => {
const reader = response.body.getReader();
const decoder = new TextDecoder();
function read() {
reader.read().then(({done, value}) => {
if (done) {
btn.disabled = false;
btn.textContent = 'Пересчитать цены';
progressText.textContent = 'Готово!';
progressBar.className = 'bg-green-600 h-4 rounded-full';
setTimeout(() => {
progressContainer.style.display = 'none';
if (currentTab === 'components') {
loadData();
}
}, 2000);
return;
}
const text = decoder.decode(value);
const lines = text.split('\n');
lines.forEach(line => {
if (line.startsWith('data:')) {
try {
const data = JSON.parse(line.substring(5).trim());
const percent = data.total > 0 ? Math.round((data.current / data.total) * 100) : 0;
progressBar.style.width = percent + '%';
progressPercent.textContent = percent + '%';
if (data.status === 'completed') {
progressText.textContent = 'Пересчёт завершён!';
progressBar.className = 'bg-green-600 h-4 rounded-full';
} else {
progressText.textContent = 'Обработка компонентов...';
}
progressStats.textContent = 'Обновлено: ' + (data.updated || 0) + ' | Ручные: ' + (data.manual || 0) + ' | Нет данных: ' + (data.skipped || 0) + ' | Ошибок: ' + (data.errors || 0);
} catch(e) {
console.log('Parse error:', e, line);
}
}
});
read();
});
}
read();
}).catch(e => {
console.error('Fetch error:', e);
alert('Ошибка соединения');
btn.disabled = false;
btn.textContent = 'Пересчитать цены';
progressContainer.style.display = 'none';
});
}
// Close modal on click outside
document.getElementById('price-modal').addEventListener('click', function(e) {
if (e.target === this) {
closeModal();
}
});
document.addEventListener('DOMContentLoaded', () => {
loadTab('alerts');
// Add event listeners for preview updates
document.getElementById('modal-method').addEventListener('change', fetchPreview);
document.getElementById('modal-period').addEventListener('change', fetchPreview);
document.getElementById('modal-coefficient').addEventListener('input', debounceFetchPreview);
});
</script>
{{end}}
{{template "base" .}}
+113
View File
@@ -0,0 +1,113 @@
{{define "base"}}
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{template "title" .}}</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<style>
.htmx-request { opacity: 0.5; }
.line-clamp-2 { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
</style>
</head>
<body class="bg-gray-100 min-h-screen">
<nav class="bg-white shadow-sm sticky top-0 z-40">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-14">
<div class="flex items-center space-x-8">
<a href="/" class="text-xl font-bold text-blue-600">QuoteForge</a>
<div class="hidden md:flex space-x-4">
<a href="/configs" class="text-gray-600 hover:text-gray-900 px-3 py-2 text-sm" id="nav-configs" style="display:none;">Мои конфигурации</a>
<a href="/admin/pricing" class="text-gray-600 hover:text-gray-900 px-3 py-2 text-sm" id="nav-admin" style="display:none;">Цены</a>
</div>
</div>
<div class="flex items-center">
<div id="user-logged-out">
<a href="/login" class="text-blue-600 hover:text-blue-800 text-sm">Войти</a>
</div>
<div id="user-logged-in" class="hidden">
<span id="user-name" class="text-sm text-gray-700 mr-3"></span>
<button onclick="logout()" class="text-red-600 hover:text-red-800 text-sm">Выйти</button>
</div>
</div>
</div>
</div>
</nav>
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 pb-12">
{{template "content" .}}
</main>
<div id="toast" class="fixed bottom-4 right-4 z-50"></div>
<footer class="fixed bottom-0 left-0 right-0 bg-gray-800 text-gray-300 text-xs py-1 px-4">
<div class="max-w-7xl mx-auto flex justify-between">
<span id="db-status">БД: проверка...</span>
<span id="db-counts"></span>
</div>
</footer>
<script>
function initAuth() {
const token = localStorage.getItem('token');
const user = localStorage.getItem('user');
if (token && user) {
try {
const userData = JSON.parse(user);
document.getElementById('user-logged-out').classList.add('hidden');
document.getElementById('user-logged-in').classList.remove('hidden');
document.getElementById('user-name').textContent = userData.username;
document.getElementById('nav-configs').style.display = 'block';
if (userData.role === 'admin' || userData.role === 'pricing_admin') {
document.getElementById('nav-admin').style.display = 'block';
}
} catch(e) {
logout();
}
}
}
function logout() {
localStorage.removeItem('token');
localStorage.removeItem('refresh_token');
localStorage.removeItem('user');
window.location.href = '/';
}
function showToast(msg, type) {
const colors = { success: 'bg-green-500', error: 'bg-red-500', info: 'bg-blue-500' };
const el = document.getElementById('toast');
el.innerHTML = '<div class="' + (colors[type] || colors.info) + ' text-white px-4 py-2 rounded shadow">' + msg + '</div>';
setTimeout(() => el.innerHTML = '', 3000);
}
async function checkDbStatus() {
try {
const resp = await fetch('/api/db-status');
const data = await resp.json();
const statusEl = document.getElementById('db-status');
const countsEl = document.getElementById('db-counts');
if (data.connected) {
statusEl.innerHTML = '<span class="text-green-400">БД: подключено</span>';
} else {
statusEl.innerHTML = '<span class="text-red-400">БД: ошибка - ' + data.error + '</span>';
}
countsEl.textContent = 'lot: ' + data.lot_count + ' | lot_log: ' + data.lot_log_count + ' | metadata: ' + data.metadata_count;
} catch(e) {
document.getElementById('db-status').innerHTML = '<span class="text-red-400">БД: нет связи</span>';
}
}
document.addEventListener('DOMContentLoaded', function() {
initAuth();
checkDbStatus();
});
</script>
</body>
</html>
{{end}}
+52
View File
@@ -0,0 +1,52 @@
{{define "components_list.html"}}
{{if .Components}}
<div class="bg-white rounded-lg shadow overflow-hidden">
<table class="w-full">
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Артикул</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Категория</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Описание</th>
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Цена</th>
<th class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase w-24"></th>
</tr>
</thead>
<tbody class="divide-y">
{{range .Components}}
<tr class="hover:bg-gray-50">
<td class="px-4 py-3 text-sm font-medium font-mono">{{.LotName}}</td>
<td class="px-4 py-3 text-sm">
<span class="px-2 py-1 text-xs rounded bg-gray-100">{{.Category}}</span>
</td>
<td class="px-4 py-3 text-sm text-gray-500 max-w-md truncate">{{.Description}}</td>
<td class="px-4 py-3 text-sm text-right font-medium">
{{if .CurrentPrice}}
${{printf "%.2f" (deref .CurrentPrice)}}
{{else}}
<span class="text-gray-400"></span>
{{end}}
</td>
<td class="px-4 py-3 text-center">
{{if .CurrentPrice}}
<button onclick="addToCart('{{jsesc .LotName}}', {{deref .CurrentPrice}}, '{{jsesc .Description}}')"
class="px-3 py-1 bg-blue-600 text-white text-xs rounded hover:bg-blue-700">
+ Добавить
</button>
{{else}}
<span class="text-gray-400 text-xs">Нет цены</span>
{{end}}
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
<p class="text-center text-sm text-gray-500 mt-4">Найдено: {{.Total}}</p>
{{else}}
<div class="bg-white rounded-lg shadow p-8 text-center text-gray-500">
Компоненты не найдены
</div>
{{end}}
{{end}}
+199
View File
@@ -0,0 +1,199 @@
{{define "title"}}Мои конфигурации - QuoteForge{{end}}
{{define "content"}}
<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>
<!-- Modal for creating new configuration -->
<div id="create-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
<div class="bg-white rounded-lg shadow-xl w-full max-w-md mx-4 p-6">
<h2 class="text-xl font-semibold mb-4">Новая конфигурация</h2>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Номер Opportunity</label>
<input type="text" id="opportunity-number" placeholder="Например: OPP-2024-001"
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
</div>
</div>
<div class="flex justify-end space-x-3 mt-6">
<button onclick="closeCreateModal()" class="px-4 py-2 text-gray-600 hover:text-gray-800">
Отмена
</button>
<button onclick="createConfig()" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">
Создать
</button>
</div>
</div>
</div>
<script>
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', {
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 || []);
} catch(e) {
document.getElementById('configs-list').innerHTML =
'<div class="bg-white rounded-lg shadow p-8 text-center text-red-600">Ошибка загрузки</div>';
}
}
function renderConfigs(configs) {
if (configs.length === 0) {
document.getElementById('configs-list').innerHTML =
'<div class="bg-white rounded-lg shadow p-8 text-center text-gray-500">Нет сохраненных конфигураций</div>';
return;
}
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-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">';
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}) : '—';
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 text-right">' + total + '</td>';
html += '<td class="px-4 py-3 text-sm text-right">';
html += '<button onclick="deleteConfig(\'' + c.uuid + '\')" class="text-red-600 hover:text-red-800">Удалить</button>';
html += '</td></tr>';
});
html += '</tbody></table></div>';
document.getElementById('configs-list').innerHTML = html;
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
async function deleteConfig(uuid) {
if (!confirm('Удалить?')) return;
const token = localStorage.getItem('token');
await fetch('/api/configs/' + uuid, {
method: 'DELETE',
headers: {'Authorization': 'Bearer ' + token}
});
loadConfigs();
}
function openCreateModal() {
const token = localStorage.getItem('token');
if (!token) {
window.location.href = '/login';
return;
}
document.getElementById('opportunity-number').value = '';
document.getElementById('create-modal').classList.remove('hidden');
document.getElementById('create-modal').classList.add('flex');
document.getElementById('opportunity-number').focus();
}
function closeCreateModal() {
document.getElementById('create-modal').classList.add('hidden');
document.getElementById('create-modal').classList.remove('flex');
}
async function createConfig() {
const token = localStorage.getItem('token');
const name = document.getElementById('opportunity-number').value.trim();
if (!name) {
alert('Введите номер Opportunity');
return;
}
try {
const resp = await fetch('/api/configs', {
method: 'POST',
headers: {
'Authorization': 'Bearer ' + token,
'Content-Type': 'application/json'
},
body: JSON.stringify({
name: name,
items: [],
notes: ''
})
});
if (resp.status === 401) {
logout();
return;
}
if (!resp.ok) {
const err = await resp.json();
alert('Ошибка: ' + (err.error || 'Не удалось создать'));
return;
}
const config = await resp.json();
window.location.href = '/configurator?uuid=' + config.uuid;
} catch(e) {
alert('Ошибка создания конфигурации');
}
}
// Close modal on outside click
document.getElementById('create-modal').addEventListener('click', function(e) {
if (e.target === this) {
closeCreateModal();
}
});
// Close modal on Escape key
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
closeCreateModal();
}
});
document.addEventListener('DOMContentLoaded', loadConfigs);
</script>
{{end}}
{{template "base" .}}
+743
View File
@@ -0,0 +1,743 @@
{{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-60 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);
}).slice(0, 50);
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';
dropdown.innerHTML = autocompleteFiltered.map((comp, idx) => `
<div class="autocomplete-item ${idx === autocompleteIndex ? 'selected' : ''}"
onmousedown="selectAutocompleteItem(${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);
}).slice(0, 50);
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" .}}
+82
View File
@@ -0,0 +1,82 @@
{{define "title"}}Вход - QuoteForge{{end}}
{{define "content"}}
<div class="max-w-sm mx-auto mt-16">
<div class="bg-white rounded-lg shadow p-6">
<h1 class="text-xl font-bold text-center mb-6">Вход в систему</h1>
<form id="login-form">
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-1">Логин</label>
<input type="text" name="username" id="username" required
class="w-full px-3 py-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
value="admin">
</div>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-1">Пароль</label>
<input type="password" name="password" id="password" required
class="w-full px-3 py-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
value="admin123">
</div>
<div id="error" class="text-red-600 text-sm mb-4 hidden"></div>
<button type="submit" id="submit-btn"
class="w-full bg-blue-600 text-white py-2 rounded hover:bg-blue-700">
Войти
</button>
</form>
<p class="text-center text-sm text-gray-500 mt-4">
<a href="/" class="text-blue-600">← На главную</a>
</p>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
const form = document.getElementById('login-form');
if (!form) return;
form.addEventListener('submit', async function(e) {
e.preventDefault();
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
const errorEl = document.getElementById('error');
const btn = document.getElementById('submit-btn');
errorEl.classList.add('hidden');
btn.disabled = true;
btn.textContent = 'Вход...';
try {
const resp = await fetch('/api/auth/login', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({username, password})
});
const data = await resp.json();
if (resp.ok && data.access_token) {
localStorage.setItem('token', data.access_token);
localStorage.setItem('refresh_token', data.refresh_token);
localStorage.setItem('user', JSON.stringify(data.user));
window.location.href = '/configs';
} else {
errorEl.textContent = data.error || 'Неверный логин или пароль';
errorEl.classList.remove('hidden');
btn.disabled = false;
btn.textContent = 'Войти';
}
} catch(err) {
errorEl.textContent = 'Ошибка соединения с сервером';
errorEl.classList.remove('hidden');
btn.disabled = false;
btn.textContent = 'Войти';
}
});
});
</script>
{{end}}
{{template "base" .}}