Files
QuoteForge/web/templates/configs.html
Mikhail Chusavitin b672cbf27d feat: implement comprehensive sync UI improvements and bug fixes
- 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
2026-02-02 12:17:17 +03:00

442 lines
18 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 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" .}}