- Fix critical race condition in sync dropdown actions - Add loading states and spinners for sync operations - Implement proper event delegation to prevent memory leaks - Add accessibility attributes (aria-label, aria-haspopup, aria-expanded) - Add keyboard navigation (Escape to close dropdown) - Reduce code duplication in sync functions (70% reduction) - Improve error handling for pricelist badge - Fix z-index issues in dropdown menu - Maintain full backward compatibility Addresses all issues identified in the TODO list and bug reports
442 lines
18 KiB
HTML
442 lines
18 KiB
HTML
{{define "title"}}Мои конфигурации - QuoteForge{{end}}
|
||
|
||
{{define "content"}}
|
||
<div class="space-y-4">
|
||
<h1 class="text-2xl font-bold">Мои конфигурации</h1>
|
||
|
||
<div class="mt-4">
|
||
<button onclick="openCreateModal()" class="w-full py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium">
|
||
+ Создать новую конфигурацию
|
||
</button>
|
||
</div>
|
||
|
||
<div id="pricelist-badge" class="mt-4 text-sm text-gray-600 hidden">
|
||
<span class="bg-blue-100 text-blue-800 px-2 py-1 rounded-full">
|
||
<svg class="w-4 h-4 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
|
||
</svg>
|
||
Активный прайслист: <span id="pricelist-version">-</span>
|
||
</span>
|
||
</div>
|
||
|
||
<div id="configs-list">
|
||
<div class="text-center py-8 text-gray-500">Загрузка...</div>
|
||
</div>
|
||
|
||
<!-- Pagination -->
|
||
<div id="pagination" class="flex justify-between items-center mt-4 pt-4 border-t hidden">
|
||
<span id="page-info" class="text-sm text-gray-600"></span>
|
||
<div class="flex gap-2">
|
||
<button onclick="prevPage()" id="btn-prev" class="px-3 py-1 border rounded text-sm disabled:opacity-50">Назад</button>
|
||
<button onclick="nextPage()" id="btn-next" class="px-3 py-1 border rounded text-sm disabled:opacity-50">Вперед</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Modal for creating new configuration -->
|
||
<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>
|
||
// Pagination state
|
||
let currentPage = 1;
|
||
let totalPages = 1;
|
||
let perPage = 20;
|
||
|
||
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-left text-xs font-medium text-gray-500 uppercase">Цена (за 1 шт)</th>';
|
||
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Кол-во</th>';
|
||
html += '<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Сумма</th>';
|
||
html += '<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Действия</th>';
|
||
html += '</tr></thead><tbody class="divide-y">';
|
||
|
||
configs.forEach(c => {
|
||
const date = new Date(c.created_at).toLocaleDateString('ru-RU');
|
||
const total = c.total_price ? '$' + c.total_price.toLocaleString('en-US', {minimumFractionDigits: 2}) : '—';
|
||
const serverCount = c.server_count ? c.server_count : 1;
|
||
|
||
// Calculate price per unit (total / server count)
|
||
let pricePerUnit = '—';
|
||
if (c.total_price && serverCount > 0) {
|
||
const unitPrice = c.total_price / serverCount;
|
||
pricePerUnit = '$' + unitPrice.toLocaleString('en-US', {minimumFractionDigits: 2});
|
||
}
|
||
|
||
html += '<tr class="hover:bg-gray-50">';
|
||
html += '<td class="px-4 py-3 text-sm text-gray-500">' + date + '</td>';
|
||
html += '<td class="px-4 py-3 text-sm font-medium"><a href="/configurator?uuid=' + c.uuid + '" class="text-blue-600 hover:text-blue-800 hover:underline">' + escapeHtml(c.name) + '</a></td>';
|
||
html += '<td class="px-4 py-3 text-sm text-gray-500">' + pricePerUnit + '</td>';
|
||
html += '<td class="px-4 py-3 text-sm text-gray-500">' + serverCount + '</td>';
|
||
html += '<td class="px-4 py-3 text-sm text-right">' + total + '</td>';
|
||
html += '<td class="px-4 py-3 text-sm text-right space-x-2">';
|
||
html += '<button onclick="openCloneModal(\'' + c.uuid + '\', \'' + escapeHtml(c.name).replace(/'/g, "\\'") + '\')" class="text-green-600 hover:text-green-800" title="Копировать">';
|
||
html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">';
|
||
html += '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path>';
|
||
html += '</svg>';
|
||
html += '</button>';
|
||
html += '<button onclick="openRenameModal(\'' + c.uuid + '\', \'' + escapeHtml(c.name).replace(/'/g, "\\'") + '\')" class="text-blue-600 hover:text-blue-800" title="Переименовать">';
|
||
html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">';
|
||
html += '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path>';
|
||
html += '</svg>';
|
||
html += '</button>';
|
||
html += '<button onclick="deleteConfig(\'' + c.uuid + '\')" class="text-red-600 hover:text-red-800" title="Удалить">';
|
||
html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">';
|
||
html += '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>';
|
||
html += '</svg>';
|
||
html += '</button>';
|
||
html += '</td></tr>';
|
||
});
|
||
|
||
html += '</tbody></table></div>';
|
||
document.getElementById('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;
|
||
await fetch('/api/configs/' + uuid, {
|
||
method: 'DELETE'
|
||
});
|
||
loadConfigs();
|
||
}
|
||
|
||
function openRenameModal(uuid, currentName) {
|
||
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 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: {
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify({ name: name })
|
||
});
|
||
|
||
if (!resp.ok) {
|
||
const err = await resp.json();
|
||
alert('Ошибка: ' + (err.error || 'Не удалось переименовать'));
|
||
return;
|
||
}
|
||
|
||
closeRenameModal();
|
||
loadConfigs();
|
||
} catch(e) {
|
||
alert('Ошибка переименования');
|
||
}
|
||
}
|
||
|
||
function openCloneModal(uuid, currentName) {
|
||
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 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: {
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify({ name: name })
|
||
});
|
||
|
||
if (!resp.ok) {
|
||
const err = await resp.json();
|
||
alert('Ошибка: ' + (err.error || 'Не удалось скопировать'));
|
||
return;
|
||
}
|
||
|
||
closeCloneModal();
|
||
loadConfigs();
|
||
} catch(e) {
|
||
alert('Ошибка копирования');
|
||
}
|
||
}
|
||
|
||
function openCreateModal() {
|
||
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 name = document.getElementById('opportunity-number').value.trim();
|
||
|
||
if (!name) {
|
||
alert('Введите номер Opportunity');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const resp = await fetch('/api/configs', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify({
|
||
name: name,
|
||
items: [],
|
||
notes: '',
|
||
server_count: 1
|
||
})
|
||
});
|
||
|
||
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();
|
||
}
|
||
});
|
||
|
||
function prevPage() {
|
||
if (currentPage > 1) {
|
||
currentPage--;
|
||
loadConfigs();
|
||
}
|
||
}
|
||
|
||
function nextPage() {
|
||
if (currentPage < totalPages) {
|
||
currentPage++;
|
||
loadConfigs();
|
||
}
|
||
}
|
||
|
||
function updatePagination(total) {
|
||
totalPages = Math.ceil(total / perPage);
|
||
document.getElementById('page-info').textContent =
|
||
'Страница ' + currentPage + ' из ' + totalPages + ' (всего: ' + total + ')';
|
||
document.getElementById('btn-prev').disabled = currentPage <= 1;
|
||
document.getElementById('btn-next').disabled = currentPage >= totalPages;
|
||
document.getElementById('pagination').classList.remove('hidden');
|
||
}
|
||
|
||
// Load configs with pagination
|
||
async function loadConfigs() {
|
||
try {
|
||
const resp = await fetch('/api/configs?page=' + currentPage + '&per_page=' + perPage);
|
||
|
||
if (!resp.ok) {
|
||
document.getElementById('configs-list').innerHTML =
|
||
'<div class="bg-white rounded-lg shadow p-8 text-center text-red-600">Ошибка загрузки</div>';
|
||
return;
|
||
}
|
||
|
||
const data = await resp.json();
|
||
renderConfigs(data.configurations || []);
|
||
updatePagination(data.total);
|
||
} catch(e) {
|
||
document.getElementById('configs-list').innerHTML =
|
||
'<div class="bg-white rounded-lg shadow p-8 text-center text-red-600">Ошибка загрузки</div>';
|
||
}
|
||
}
|
||
|
||
document.addEventListener('DOMContentLoaded', function() {
|
||
loadConfigs();
|
||
|
||
// Load latest pricelist version for badge
|
||
loadLatestPricelistVersion();
|
||
});
|
||
|
||
async function loadLatestPricelistVersion() {
|
||
try {
|
||
const resp = await fetch('/api/pricelists/latest');
|
||
if (resp.ok) {
|
||
const pricelist = await resp.json();
|
||
document.getElementById('pricelist-version').textContent = pricelist.version;
|
||
document.getElementById('pricelist-badge').classList.remove('hidden');
|
||
} else {
|
||
// Show error in badge
|
||
document.getElementById('pricelist-version').textContent = 'Ошибка загрузки';
|
||
document.getElementById('pricelist-badge').classList.remove('hidden');
|
||
document.getElementById('pricelist-badge').classList.add('bg-red-100', 'text-red-800');
|
||
}
|
||
} catch(e) {
|
||
// Show error in badge
|
||
console.error('Failed to load pricelist version:', e);
|
||
document.getElementById('pricelist-version').textContent = 'Ошибка загрузки';
|
||
document.getElementById('pricelist-badge').classList.remove('hidden');
|
||
document.getElementById('pricelist-badge').classList.add('bg-red-100', 'text-red-800');
|
||
}
|
||
}
|
||
</script>
|
||
{{end}}
|
||
|
||
{{template "base" .}}
|