Files
QuoteForge/web/templates/configs.html
Mikhail Chusavitin d32b1c5d0c Добавлены сортировка по категориям, секции PCI и автосохранение
Основные изменения:

1. CSV экспорт и веб-интерфейс:
   - Компоненты теперь сортируются по иерархии категорий (display_order)
   - Категории отображаются в правильном порядке: BB, CPU, MEM, GPU и т.д.
   - Компоненты без категории отображаются в конце

2. Раздел PCI в конфигураторе:
   - Разделен на секции: GPU/DPU, NIC/HCA, HBA
   - Улучшена навигация и выбор компонентов

3. Сохранение "своей цены":
   - Добавлено поле custom_price в модель Configuration
   - Создана миграция 002_add_custom_price.sql
   - "Своя цена" сохраняется при сохранении конфигурации
   - При загрузке конфигурации восстанавливается сохраненная цена

4. Автосохранение:
   - Конфигурация автоматически сохраняется через 1 секунду после изменений
   - Debounce предотвращает избыточные запросы
   - Автосохранение работает для всех изменений (компоненты, количество, цена)

5. Дополнительно:
   - Добавлен cmd/importer для импорта метаданных из таблицы lot
   - Создан скрипт apply_migration.sh для применения миграций
   - Оптимизирована работа с категориями в ExportService

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-30 17:48:44 +03:00

394 lines
14 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">
<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>
<!-- Modal for renaming configuration -->
<div id="rename-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">Новое название</label>
<input type="text" id="rename-input" placeholder="Введите новое название"
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<input type="hidden" id="rename-uuid">
</div>
</div>
<div class="flex justify-end space-x-3 mt-6">
<button onclick="closeRenameModal()" class="px-4 py-2 text-gray-600 hover:text-gray-800">
Отмена
</button>
<button onclick="renameConfig()" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">
Сохранить
</button>
</div>
</div>
</div>
<!-- Modal for cloning configuration -->
<div id="clone-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">Название копии</label>
<input type="text" id="clone-input" placeholder="Введите название"
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<input type="hidden" id="clone-uuid">
</div>
</div>
<div class="flex justify-end space-x-3 mt-6">
<button onclick="closeCloneModal()" class="px-4 py-2 text-gray-600 hover:text-gray-800">
Отмена
</button>
<button onclick="cloneConfig()" class="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-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 space-x-2">';
html += '<button onclick="openCloneModal(\'' + c.uuid + '\', \'' + escapeHtml(c.name).replace(/'/g, "\\'") + '\')" class="text-green-600 hover:text-green-800">Копировать</button>';
html += '<button onclick="openRenameModal(\'' + c.uuid + '\', \'' + escapeHtml(c.name).replace(/'/g, "\\'") + '\')" class="text-blue-600 hover:text-blue-800">Переименовать</button>';
html += '<button onclick="deleteConfig(\'' + c.uuid + '\')" class="text-red-600 hover:text-red-800">Удалить</button>';
html += '</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 openRenameModal(uuid, currentName) {
const token = localStorage.getItem('token');
if (!token) {
window.location.href = '/login';
return;
}
document.getElementById('rename-uuid').value = uuid;
document.getElementById('rename-input').value = currentName;
document.getElementById('rename-modal').classList.remove('hidden');
document.getElementById('rename-modal').classList.add('flex');
document.getElementById('rename-input').focus();
document.getElementById('rename-input').select();
}
function closeRenameModal() {
document.getElementById('rename-modal').classList.add('hidden');
document.getElementById('rename-modal').classList.remove('flex');
}
async function renameConfig() {
const token = localStorage.getItem('token');
const uuid = document.getElementById('rename-uuid').value;
const name = document.getElementById('rename-input').value.trim();
if (!name) {
alert('Введите название');
return;
}
try {
const resp = await fetch('/api/configs/' + uuid + '/rename', {
method: 'PATCH',
headers: {
'Authorization': 'Bearer ' + token,
'Content-Type': 'application/json'
},
body: JSON.stringify({ name: name })
});
if (resp.status === 401) {
logout();
return;
}
if (!resp.ok) {
const err = await resp.json();
alert('Ошибка: ' + (err.error || 'Не удалось переименовать'));
return;
}
closeRenameModal();
loadConfigs();
} catch(e) {
alert('Ошибка переименования');
}
}
function openCloneModal(uuid, currentName) {
const token = localStorage.getItem('token');
if (!token) {
window.location.href = '/login';
return;
}
document.getElementById('clone-uuid').value = uuid;
document.getElementById('clone-input').value = currentName + ' (копия)';
document.getElementById('clone-modal').classList.remove('hidden');
document.getElementById('clone-modal').classList.add('flex');
document.getElementById('clone-input').focus();
document.getElementById('clone-input').select();
}
function closeCloneModal() {
document.getElementById('clone-modal').classList.add('hidden');
document.getElementById('clone-modal').classList.remove('flex');
}
async function cloneConfig() {
const token = localStorage.getItem('token');
const uuid = document.getElementById('clone-uuid').value;
const name = document.getElementById('clone-input').value.trim();
if (!name) {
alert('Введите название');
return;
}
try {
const resp = await fetch('/api/configs/' + uuid + '/clone', {
method: 'POST',
headers: {
'Authorization': 'Bearer ' + token,
'Content-Type': 'application/json'
},
body: JSON.stringify({ name: name })
});
if (resp.status === 401) {
logout();
return;
}
if (!resp.ok) {
const err = await resp.json();
alert('Ошибка: ' + (err.error || 'Не удалось скопировать'));
return;
}
closeCloneModal();
loadConfigs();
} catch(e) {
alert('Ошибка копирования');
}
}
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();
}
});
document.getElementById('rename-modal').addEventListener('click', function(e) {
if (e.target === this) {
closeRenameModal();
}
});
document.getElementById('clone-modal').addEventListener('click', function(e) {
if (e.target === this) {
closeCloneModal();
}
});
// Close modal on Escape key
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
closeCreateModal();
closeRenameModal();
closeCloneModal();
}
});
// Submit rename on Enter key
document.getElementById('rename-input').addEventListener('keydown', function(e) {
if (e.key === 'Enter') {
renameConfig();
}
});
// Submit clone on Enter key
document.getElementById('clone-input').addEventListener('keydown', function(e) {
if (e.key === 'Enter') {
cloneConfig();
}
});
document.addEventListener('DOMContentLoaded', loadConfigs);
</script>
{{end}}
{{template "base" .}}