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:
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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}}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user