feat: move pricelists to admin pricing tab\n\n- Removed separate 'Прайслисты' link from navigation\n- Added 4th tab 'Прайслисты' to admin_pricing.html\n- Moved pricelists table, create modal, and CRUD functionality to admin pricing\n- Updated /pricelists route to redirect to /admin/pricing?tab=pricelists\n\nFixes task 2: Прайслисты → вкладка в "Администратор цен"

This commit is contained in:
Mikhail Chusavitin
2026-02-02 12:42:05 +03:00
parent 96bbe0a510
commit ded6e09b5e
3 changed files with 244 additions and 3 deletions

View File

@@ -427,7 +427,10 @@ func setupRouter(db *gorm.DB, cfg *config.Config, local *localdb.LocalDB, dbUser
router.GET("/", webHandler.Index) router.GET("/", webHandler.Index)
router.GET("/configs", webHandler.Configs) router.GET("/configs", webHandler.Configs)
router.GET("/configurator", webHandler.Configurator) router.GET("/configurator", webHandler.Configurator)
router.GET("/pricelists", webHandler.Pricelists) router.GET("/pricelists", func(c *gin.Context) {
// Redirect to admin/pricing with pricelists tab
c.Redirect(http.StatusFound, "/admin/pricing?tab=pricelists")
})
router.GET("/pricelists/:id", webHandler.PricelistDetail) router.GET("/pricelists/:id", webHandler.PricelistDetail)
router.GET("/admin/pricing", webHandler.AdminPricing) router.GET("/admin/pricing", webHandler.AdminPricing)

View File

@@ -9,6 +9,7 @@
<div class="flex gap-4"> <div class="flex gap-4">
<button onclick="loadTab('alerts')" id="btn-alerts" class="text-blue-600 font-medium">Алерты</button> <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> <button onclick="loadTab('components')" id="btn-components" class="text-gray-600">Компоненты</button>
<button onclick="loadTab('pricelists')" id="btn-pricelists" class="text-gray-600">Прайслисты</button>
<button onclick="loadTab('all-configs')" id="btn-all-configs" class="text-gray-600 hidden">Все конфигурации</button> <button onclick="loadTab('all-configs')" id="btn-all-configs" class="text-gray-600 hidden">Все конфигурации</button>
</div> </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 onclick="recalculateAll()" id="btn-recalc" class="px-3 py-1 bg-green-600 text-white text-sm rounded hover:bg-green-700">
@@ -53,6 +54,60 @@
<div class="text-center py-8 text-gray-500">Загрузка...</div> <div class="text-center py-8 text-gray-500">Загрузка...</div>
</div> </div>
<!-- Pricelists Tab Content (hidden by default) -->
<div id="pricelists-tab-content" class="hidden">
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-semibold">Прайслисты</h2>
<div id="pricelists-create-btn-container"></div>
</div>
<div class="bg-white rounded-lg shadow overflow-hidden">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Версия</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Дата</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Автор</th>
<th class="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase">Позиций</th>
<th class="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase">Исп.</th>
<th class="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase">Статус</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">Действия</th>
</tr>
</thead>
<tbody id="pricelists-body" class="bg-white divide-y divide-gray-200">
<tr>
<td colspan="7" class="px-6 py-4 text-center text-gray-500">Загрузка...</td>
</tr>
</tbody>
</table>
</div>
<div id="pricelists-pagination" class="flex justify-center space-x-2 mt-4"></div>
</div>
<!-- Create Modal -->
<div id="pricelists-create-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
<div class="bg-white rounded-lg p-6 max-w-md w-full mx-4">
<h2 class="text-xl font-bold mb-4">Создать прайслист</h2>
<p class="text-sm text-gray-600 mb-4">
Будет создан снимок текущих цен из базы данных.<br>
Автор прайслиста: <span id="pricelists-db-username" class="font-medium">загрузка...</span>
</p>
<form id="pricelists-create-form" class="space-y-4">
<div class="flex justify-end space-x-3">
<button type="button" onclick="closePricelistsCreateModal()"
class="px-4 py-2 text-gray-700 border border-gray-300 rounded-md hover:bg-gray-50">
Отмена
</button>
<button type="submit"
class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700">
Создать
</button>
</div>
</form>
</div>
</div>
<!-- Pagination --> <!-- Pagination -->
<div id="pagination" class="flex justify-between items-center mt-4 pt-4 border-t hidden"> <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> <span id="page-info" class="text-sm text-gray-600"></span>
@@ -157,6 +212,7 @@ let currentSearch = '';
let componentsCache = []; let componentsCache = [];
let sortField = 'popularity_score'; let sortField = 'popularity_score';
let sortDir = 'desc'; let sortDir = 'desc';
let pricelistsPage = 1;
async function loadTab(tab) { async function loadTab(tab) {
currentTab = tab; currentTab = tab;
@@ -166,6 +222,7 @@ async function loadTab(tab) {
document.getElementById('btn-alerts').className = tab === 'alerts' ? 'text-blue-600 font-medium' : 'text-gray-600'; 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('btn-components').className = tab === 'components' ? 'text-blue-600 font-medium' : 'text-gray-600';
document.getElementById('btn-pricelists').className = tab === 'pricelists' ? 'text-blue-600 font-medium' : 'text-gray-600';
document.getElementById('btn-all-configs').className = tab === 'all-configs' ? 'text-blue-600 font-medium' : 'text-gray-600 hidden'; document.getElementById('btn-all-configs').className = tab === 'all-configs' ? 'text-blue-600 font-medium' : 'text-gray-600 hidden';
// Show/hide elements based on tab // Show/hide elements based on tab
@@ -173,17 +230,30 @@ async function loadTab(tab) {
document.getElementById('search-bar').className = 'mb-4'; document.getElementById('search-bar').className = 'mb-4';
document.getElementById('pagination').className = 'flex justify-between items-center mt-4 pt-4 border-t'; document.getElementById('pagination').className = 'flex justify-between items-center mt-4 pt-4 border-t';
document.getElementById('btn-all-configs').className = 'text-gray-600 hidden'; // Hide this tab for components document.getElementById('btn-all-configs').className = 'text-gray-600 hidden'; // Hide this tab for components
document.getElementById('pricelists-tab-content').className = 'hidden';
} else if (tab === 'all-configs') { } else if (tab === 'all-configs') {
document.getElementById('search-bar').className = 'mb-4 hidden'; // Hide search for all configs document.getElementById('search-bar').className = 'mb-4 hidden'; // Hide search for all configs
document.getElementById('pagination').className = 'flex justify-between items-center mt-4 pt-4 border-t'; // Show pagination document.getElementById('pagination').className = 'flex justify-between items-center mt-4 pt-4 border-t'; // Show pagination
document.getElementById('btn-all-configs').className = 'text-blue-600 font-medium'; // Show this tab for all configs document.getElementById('btn-all-configs').className = 'text-blue-600 font-medium'; // Show this tab for all configs
document.getElementById('pricelists-tab-content').className = 'hidden';
} else if (tab === 'pricelists') {
document.getElementById('search-bar').className = 'mb-4 hidden';
document.getElementById('pagination').className = 'hidden';
document.getElementById('btn-all-configs').className = 'text-gray-600 hidden';
document.getElementById('pricelists-tab-content').className = '';
// Load pricelists when pricelists tab is selected
checkPricelistWritePermission();
loadPricelists(1);
} else { } else {
document.getElementById('search-bar').className = 'mb-4 hidden'; document.getElementById('search-bar').className = 'mb-4 hidden';
document.getElementById('pagination').className = 'hidden'; document.getElementById('pagination').className = 'hidden';
document.getElementById('btn-all-configs').className = 'text-gray-600 hidden'; document.getElementById('btn-all-configs').className = 'text-gray-600 hidden';
document.getElementById('pricelists-tab-content').className = 'hidden';
} }
await loadData(); if (tab !== 'pricelists') {
await loadData();
}
} }
async function loadData() { async function loadData() {
@@ -811,6 +881,175 @@ document.addEventListener('DOMContentLoaded', () => {
document.getElementById('modal-manual-price').addEventListener('input', debounceFetchPreview); document.getElementById('modal-manual-price').addEventListener('input', debounceFetchPreview);
document.getElementById('modal-meta-prices').addEventListener('input', debounceFetchPreview); document.getElementById('modal-meta-prices').addEventListener('input', debounceFetchPreview);
}); });
// Pricelists functions
let canWrite = false;
async function checkPricelistWritePermission() {
try {
const resp = await fetch('/api/pricelists/can-write');
const data = await resp.json();
canWrite = data.can_write;
if (canWrite) {
document.getElementById('pricelists-create-btn-container').innerHTML = `
<button onclick="openPricelistsCreateModal()" class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700">
Создать прайслист
</button>
`;
}
} catch (e) {
console.error('Failed to check pricelist write permission:', e);
}
}
async function loadPricelists(page = 1) {
pricelistsPage = page;
try {
const resp = await fetch(`/api/pricelists?page=${page}&per_page=20`);
const data = await resp.json();
renderPricelists(data.pricelists || []);
renderPricelistsPagination(data.total, data.page, data.per_page);
} catch (e) {
document.getElementById('pricelists-body').innerHTML = `
<tr>
<td colspan="7" class="px-6 py-4 text-center text-red-500">
Ошибка загрузки: ${e.message}
</td>
</tr>
`;
}
}
function renderPricelists(pricelists) {
if (pricelists.length === 0) {
document.getElementById('pricelists-body').innerHTML = `
<tr>
<td colspan="7" class="px-6 py-4 text-center text-gray-500">
Прайслисты не найдены. ${canWrite ? 'Создайте первый прайслист.' : ''}
</td>
</tr>
`;
return;
}
const html = pricelists.map(pl => {
const date = new Date(pl.created_at).toLocaleDateString('ru-RU');
const statusClass = pl.is_active ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800';
const statusText = pl.is_active ? 'Активен' : 'Неактивен';
let actions = `<a href="/pricelists/${pl.id}" class="text-blue-600 hover:text-blue-800 text-sm">Просмотр</a>`;
if (canWrite && pl.usage_count === 0) {
actions += ` <button onclick="deletePricelist(${pl.id})" class="text-red-600 hover:text-red-800 text-sm ml-2">Удалить</button>`;
}
return `
<tr class="hover:bg-gray-50">
<td class="px-6 py-4 whitespace-nowrap">
<span class="font-mono text-sm">${pl.version}</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">${date}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">${pl.created_by || '-'}</td>
<td class="px-6 py-4 whitespace-nowrap text-center text-sm">${pl.item_count}</td>
<td class="px-6 py-4 whitespace-nowrap text-center text-sm">${pl.usage_count}</td>
<td class="px-6 py-4 whitespace-nowrap text-center">
<span class="px-2 py-1 text-xs rounded-full ${statusClass}">${statusText}</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-right">${actions}</td>
</tr>
`;
}).join('');
document.getElementById('pricelists-body').innerHTML = html;
}
function renderPricelistsPagination(total, page, perPage) {
const totalPages = Math.ceil(total / perPage);
if (totalPages <= 1) {
document.getElementById('pricelists-pagination').innerHTML = '';
return;
}
let html = '';
for (let i = 1; i <= totalPages; i++) {
const activeClass = i === page ? 'bg-blue-600 text-white' : 'bg-white text-gray-700 hover:bg-gray-50';
html += `<button onclick="loadPricelists(${i})" class="px-3 py-1 rounded border ${activeClass}">${i}</button>`;
}
document.getElementById('pricelists-pagination').innerHTML = html;
}
async function loadPricelistsDbUsername() {
try {
const resp = await fetch('/api/current-user');
const data = await resp.json();
document.getElementById('pricelists-db-username').textContent = data.username || 'неизвестно';
} catch (e) {
document.getElementById('pricelists-db-username').textContent = 'неизвестно';
}
}
function openPricelistsCreateModal() {
document.getElementById('pricelists-create-modal').classList.remove('hidden');
document.getElementById('pricelists-create-modal').classList.add('flex');
loadPricelistsDbUsername();
}
function closePricelistsCreateModal() {
document.getElementById('pricelists-create-modal').classList.add('hidden');
document.getElementById('pricelists-create-modal').classList.remove('flex');
}
async function createPricelist() {
const resp = await fetch('/api/pricelists', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({})
});
if (!resp.ok) {
const data = await resp.json();
throw new Error(data.error || 'Failed to create pricelist');
}
return await resp.json();
}
async function deletePricelist(id) {
if (!confirm('Удалить этот прайслист?')) return;
try {
const resp = await fetch(`/api/pricelists/${id}`, {
method: 'DELETE'
});
if (!resp.ok) {
const data = await resp.json();
throw new Error(data.error || 'Failed to delete');
}
showToast('Прайслист удален', 'success');
loadPricelists(pricelistsPage);
} catch (e) {
showToast('Ошибка: ' + e.message, 'error');
}
}
document.getElementById('pricelists-create-form').addEventListener('submit', async function(e) {
e.preventDefault();
try {
const pl = await createPricelist();
closePricelistsCreateModal();
showToast(`Прайслист ${pl.version} создан (${pl.item_count} позиций)`, 'success');
loadPricelists(1);
} catch (e) {
showToast('Ошибка: ' + e.message, 'error');
}
});
</script> </script>
{{end}} {{end}}

View File

@@ -19,7 +19,6 @@
<div class="flex items-center space-x-8"> <div class="flex items-center space-x-8">
<a href="/" class="text-xl font-bold text-blue-600">QuoteForge</a> <a href="/" class="text-xl font-bold text-blue-600">QuoteForge</a>
<div class="hidden md:flex space-x-4"> <div class="hidden md:flex space-x-4">
<a href="/pricelists" class="text-gray-600 hover:text-gray-900 px-3 py-2 text-sm">Прайслисты</a>
<a href="/configurator" class="text-gray-600 hover:text-gray-900 px-3 py-2 text-sm">Конфигуратор</a> <a href="/configurator" class="text-gray-600 hover:text-gray-900 px-3 py-2 text-sm">Конфигуратор</a>
<a id="admin-pricing-link" href="/admin/pricing" class="text-gray-600 hover:text-gray-900 px-3 py-2 text-sm hidden">Администратор цен</a> <a id="admin-pricing-link" href="/admin/pricing" class="text-gray-600 hover:text-gray-900 px-3 py-2 text-sm hidden">Администратор цен</a>
<a href="/setup" class="text-gray-600 hover:text-gray-900 px-3 py-2 text-sm">Настройки</a> <a href="/setup" class="text-gray-600 hover:text-gray-900 px-3 py-2 text-sm">Настройки</a>