Add Phase 2: Local SQLite database with sync functionality
Implements complete offline-first architecture with SQLite caching and MariaDB synchronization. Key features: - Local SQLite database for offline operation (data/quoteforge.db) - Connection settings with encrypted credentials - Component and pricelist caching with auto-sync - Sync API endpoints (/api/sync/status, /components, /pricelists, /all) - Real-time sync status indicator in UI with auto-refresh - Offline mode detection middleware - Migration tool for database initialization - Setup wizard for initial configuration New components: - internal/localdb: SQLite repository layer (components, pricelists, sync) - internal/services/sync: Synchronization service - internal/handlers/sync: Sync API handlers - internal/handlers/setup: Setup wizard handlers - internal/middleware/offline: Offline detection - cmd/migrate: Database migration tool UI improvements: - Setup page for database configuration - Sync status indicator with online/offline detection - Warning icons for pending synchronization - Auto-refresh every 30 seconds Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -187,21 +187,11 @@ async function loadTab(tab) {
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) {
|
||||
window.location.href = '/login';
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById('tab-content').innerHTML = '<div class="text-center py-8 text-gray-500">Загрузка...</div>';
|
||||
|
||||
try {
|
||||
if (currentTab === 'alerts') {
|
||||
const resp = await fetch('/admin/pricing/alerts?per_page=100', {
|
||||
headers: {'Authorization': 'Bearer ' + token}
|
||||
});
|
||||
if (resp.status === 401) { logout(); return; }
|
||||
if (resp.status === 403) { window.location.href = '/'; return; }
|
||||
const resp = await fetch('/api/admin/pricing/alerts?per_page=100');
|
||||
const data = await resp.json();
|
||||
renderAlerts(data.alerts || []);
|
||||
} else if (currentTab === 'all-configs') {
|
||||
@@ -210,17 +200,13 @@ async function loadData() {
|
||||
if (currentSearch) {
|
||||
url += '&search=' + encodeURIComponent(currentSearch);
|
||||
}
|
||||
const resp = await fetch(url, {
|
||||
headers: {'Authorization': 'Bearer ' + token}
|
||||
});
|
||||
if (resp.status === 401) { logout(); return; }
|
||||
if (resp.status === 403) { window.location.href = '/'; return; }
|
||||
const resp = await fetch(url);
|
||||
const data = await resp.json();
|
||||
totalPages = Math.ceil(data.total / perPage);
|
||||
renderAllConfigs(data.configurations || []);
|
||||
updatePagination(data.total);
|
||||
} else {
|
||||
let url = '/admin/pricing/components?page=' + currentPage + '&per_page=' + perPage;
|
||||
let url = '/api/admin/pricing/components?page=' + currentPage + '&per_page=' + perPage;
|
||||
if (currentSearch) {
|
||||
url += '&search=' + encodeURIComponent(currentSearch);
|
||||
}
|
||||
@@ -230,10 +216,7 @@ async function loadData() {
|
||||
if (sortDir) {
|
||||
url += '&dir=' + encodeURIComponent(sortDir);
|
||||
}
|
||||
const resp = await fetch(url, {
|
||||
headers: {'Authorization': 'Bearer ' + token}
|
||||
});
|
||||
if (resp.status === 401) { logout(); return; }
|
||||
const resp = await fetch(url);
|
||||
const data = await resp.json();
|
||||
totalPages = Math.ceil(data.total / perPage);
|
||||
componentsCache = data.components || [];
|
||||
@@ -471,9 +454,6 @@ function onMethodChange() {
|
||||
}
|
||||
|
||||
async function fetchPreview() {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) return;
|
||||
|
||||
const lotName = document.getElementById('modal-lot-name').value;
|
||||
const method = document.getElementById('modal-method').value;
|
||||
const periodDays = parseInt(document.getElementById('modal-period').value) || 0;
|
||||
@@ -490,10 +470,9 @@ async function fetchPreview() {
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await fetch('/admin/pricing/preview', {
|
||||
const resp = await fetch('/api/admin/pricing/preview', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': 'Bearer ' + token,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
@@ -508,8 +487,6 @@ async function fetchPreview() {
|
||||
})
|
||||
});
|
||||
|
||||
if (resp.status === 401) { logout(); return; }
|
||||
|
||||
if (resp.ok) {
|
||||
const data = await resp.json();
|
||||
|
||||
@@ -584,12 +561,6 @@ function debounceFetchPreview() {
|
||||
}
|
||||
|
||||
async function savePrice() {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) {
|
||||
window.location.href = '/login';
|
||||
return;
|
||||
}
|
||||
|
||||
const lotName = document.getElementById('modal-lot-name').value;
|
||||
const method = document.getElementById('modal-method').value;
|
||||
const periodDaysStr = document.getElementById('modal-period').value;
|
||||
@@ -630,17 +601,14 @@ async function savePrice() {
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await fetch('/admin/pricing/update', {
|
||||
const resp = await fetch('/api/admin/pricing/update', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': 'Bearer ' + token,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
|
||||
if (resp.status === 401) { logout(); return; }
|
||||
|
||||
if (resp.ok) {
|
||||
closeModal();
|
||||
loadData();
|
||||
@@ -683,12 +651,6 @@ function processMetaPrices(metaPrices, originalLotName) {
|
||||
}
|
||||
|
||||
function recalculateAll() {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) {
|
||||
window.location.href = '/login';
|
||||
return;
|
||||
}
|
||||
|
||||
const btn = document.getElementById('btn-recalc');
|
||||
const progressContainer = document.getElementById('progress-container');
|
||||
const progressBar = document.getElementById('progress-bar');
|
||||
@@ -707,9 +669,8 @@ function recalculateAll() {
|
||||
progressStats.textContent = 'Подготовка...';
|
||||
|
||||
// Use fetch with streaming for SSE
|
||||
fetch('/admin/pricing/recalculate-all', {
|
||||
method: 'POST',
|
||||
headers: {'Authorization': 'Bearer ' + token}
|
||||
fetch('/api/admin/pricing/recalculate-all', {
|
||||
method: 'POST'
|
||||
}).then(response => {
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
@@ -19,18 +19,18 @@
|
||||
<div class="flex items-center space-x-8">
|
||||
<a href="/" class="text-xl font-bold text-blue-600">QuoteForge</a>
|
||||
<div class="hidden md:flex space-x-4">
|
||||
<a href="/configs" class="text-gray-600 hover:text-gray-900 px-3 py-2 text-sm" id="nav-configs" style="display:none;">Мои конфигурации</a>
|
||||
<a href="/admin/pricing" class="text-gray-600 hover:text-gray-900 px-3 py-2 text-sm" id="nav-admin" style="display:none;">Цены</a>
|
||||
<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 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>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<div id="user-logged-out">
|
||||
<a href="/login" class="text-blue-600 hover:text-blue-800 text-sm">Войти</a>
|
||||
</div>
|
||||
<div id="user-logged-in" class="hidden">
|
||||
<span id="user-name" class="text-sm text-gray-700 mr-3"></span>
|
||||
<button onclick="logout()" class="text-red-600 hover:text-red-800 text-sm">Выйти</button>
|
||||
<div class="flex items-center space-x-4">
|
||||
<!-- Sync Status Indicator -->
|
||||
<div id="sync-indicator" class="flex items-center space-x-2">
|
||||
<span class="animate-pulse text-gray-400 text-xs">Загрузка...</span>
|
||||
</div>
|
||||
<span id="db-user" class="text-sm text-gray-600"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -50,33 +50,6 @@
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
function initAuth() {
|
||||
const token = localStorage.getItem('token');
|
||||
const user = localStorage.getItem('user');
|
||||
|
||||
if (token && user) {
|
||||
try {
|
||||
const userData = JSON.parse(user);
|
||||
document.getElementById('user-logged-out').classList.add('hidden');
|
||||
document.getElementById('user-logged-in').classList.remove('hidden');
|
||||
document.getElementById('user-name').textContent = userData.username;
|
||||
document.getElementById('nav-configs').style.display = 'block';
|
||||
if (userData.role === 'admin' || userData.role === 'pricing_admin') {
|
||||
document.getElementById('nav-admin').style.display = 'block';
|
||||
}
|
||||
} catch(e) {
|
||||
logout();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function logout() {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('refresh_token');
|
||||
localStorage.removeItem('user');
|
||||
window.location.href = '/';
|
||||
}
|
||||
|
||||
function showToast(msg, type) {
|
||||
const colors = { success: 'bg-green-500', error: 'bg-red-500', info: 'bg-blue-500' };
|
||||
const el = document.getElementById('toast');
|
||||
@@ -84,15 +57,85 @@
|
||||
setTimeout(() => el.innerHTML = '', 3000);
|
||||
}
|
||||
|
||||
async function checkSyncStatus() {
|
||||
try {
|
||||
const resp = await fetch('/api/sync/status');
|
||||
const data = await resp.json();
|
||||
updateSyncIndicator(data);
|
||||
} catch(e) {
|
||||
console.error('Failed to check sync status:', e);
|
||||
const indicator = document.getElementById('sync-indicator');
|
||||
if (indicator) {
|
||||
indicator.innerHTML = '<span class="w-2 h-2 rounded-full bg-red-500"></span><span class="text-xs text-red-600">Offline</span>';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updateSyncIndicator(data) {
|
||||
const indicator = document.getElementById('sync-indicator');
|
||||
if (!indicator) return;
|
||||
|
||||
const statusColor = data.is_online ? 'bg-green-500' : 'bg-red-500';
|
||||
const statusText = data.is_online ? 'Online' : 'Offline';
|
||||
const textColor = data.is_online ? 'text-green-700' : 'text-red-700';
|
||||
|
||||
const needSync = data.need_component_sync || data.need_pricelist_sync;
|
||||
const syncWarning = needSync ? '<span class="text-yellow-600 ml-1" title="Требуется синхронизация">⚠</span>' : '';
|
||||
|
||||
let html = `
|
||||
<div class="flex items-center space-x-2">
|
||||
<span class="w-2 h-2 rounded-full ${statusColor}" title="${statusText}"></span>
|
||||
<span class="text-xs ${textColor}">${statusText}</span>
|
||||
${syncWarning}
|
||||
${data.is_online ? `
|
||||
<button onclick="syncAll()"
|
||||
class="text-xs px-2 py-1 bg-blue-500 text-white rounded hover:bg-blue-600 transition"
|
||||
title="Синхронизировать все">
|
||||
Sync
|
||||
</button>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
|
||||
indicator.innerHTML = html;
|
||||
}
|
||||
|
||||
async function syncAll() {
|
||||
const btn = event.target;
|
||||
btn.disabled = true;
|
||||
btn.textContent = '...';
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/sync/all', { method: 'POST' });
|
||||
const data = await resp.json();
|
||||
|
||||
if (data.success) {
|
||||
showToast(`Синхронизация завершена: компоненты ${data.components_synced}, прайслисты ${data.pricelists_synced}`, 'success');
|
||||
checkSyncStatus();
|
||||
} else {
|
||||
showToast('Ошибка синхронизации: ' + (data.error || 'неизвестная ошибка'), 'error');
|
||||
}
|
||||
} catch(e) {
|
||||
showToast('Ошибка синхронизации: ' + e.message, 'error');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Sync';
|
||||
}
|
||||
}
|
||||
|
||||
async function checkDbStatus() {
|
||||
try {
|
||||
const resp = await fetch('/api/db-status');
|
||||
const data = await resp.json();
|
||||
const statusEl = document.getElementById('db-status');
|
||||
const countsEl = document.getElementById('db-counts');
|
||||
const userEl = document.getElementById('db-user');
|
||||
|
||||
if (data.connected) {
|
||||
statusEl.innerHTML = '<span class="text-green-400">БД: подключено</span>';
|
||||
if (data.db_user) {
|
||||
userEl.innerHTML = '<span class="text-gray-500">@</span>' + data.db_user;
|
||||
}
|
||||
} else {
|
||||
statusEl.innerHTML = '<span class="text-red-400">БД: ошибка - ' + data.error + '</span>';
|
||||
}
|
||||
@@ -103,9 +146,25 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function checkWritePermission() {
|
||||
try {
|
||||
const resp = await fetch('/api/pricelists/can-write');
|
||||
const data = await resp.json();
|
||||
if (data.can_write) {
|
||||
const link = document.getElementById('admin-pricing-link');
|
||||
if (link) link.classList.remove('hidden');
|
||||
}
|
||||
} catch(e) {
|
||||
console.error('Failed to check write permission:', e);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
initAuth();
|
||||
checkDbStatus();
|
||||
checkWritePermission();
|
||||
checkSyncStatus();
|
||||
// Auto-refresh sync status every 30 seconds
|
||||
setInterval(checkSyncStatus, 30000);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
|
||||
@@ -99,38 +99,10 @@
|
||||
</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>';
|
||||
}
|
||||
}
|
||||
// Pagination state
|
||||
let currentPage = 1;
|
||||
let totalPages = 1;
|
||||
let perPage = 20;
|
||||
|
||||
function renderConfigs(configs) {
|
||||
if (configs.length === 0) {
|
||||
@@ -198,20 +170,13 @@ function escapeHtml(text) {
|
||||
|
||||
async function deleteConfig(uuid) {
|
||||
if (!confirm('Удалить?')) return;
|
||||
const token = localStorage.getItem('token');
|
||||
await fetch('/api/configs/' + uuid, {
|
||||
method: 'DELETE',
|
||||
headers: {'Authorization': 'Bearer ' + token}
|
||||
method: 'DELETE'
|
||||
});
|
||||
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');
|
||||
@@ -226,7 +191,6 @@ function closeRenameModal() {
|
||||
}
|
||||
|
||||
async function renameConfig() {
|
||||
const token = localStorage.getItem('token');
|
||||
const uuid = document.getElementById('rename-uuid').value;
|
||||
const name = document.getElementById('rename-input').value.trim();
|
||||
|
||||
@@ -239,17 +203,11 @@ async function renameConfig() {
|
||||
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 || 'Не удалось переименовать'));
|
||||
@@ -264,11 +222,6 @@ async function renameConfig() {
|
||||
}
|
||||
|
||||
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');
|
||||
@@ -283,7 +236,6 @@ function closeCloneModal() {
|
||||
}
|
||||
|
||||
async function cloneConfig() {
|
||||
const token = localStorage.getItem('token');
|
||||
const uuid = document.getElementById('clone-uuid').value;
|
||||
const name = document.getElementById('clone-input').value.trim();
|
||||
|
||||
@@ -296,17 +248,11 @@ async function cloneConfig() {
|
||||
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 || 'Не удалось скопировать'));
|
||||
@@ -321,11 +267,6 @@ async function cloneConfig() {
|
||||
}
|
||||
|
||||
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');
|
||||
@@ -338,7 +279,6 @@ function closeCreateModal() {
|
||||
}
|
||||
|
||||
async function createConfig() {
|
||||
const token = localStorage.getItem('token');
|
||||
const name = document.getElementById('opportunity-number').value.trim();
|
||||
|
||||
if (!name) {
|
||||
@@ -350,7 +290,6 @@ async function createConfig() {
|
||||
const resp = await fetch('/api/configs', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': 'Bearer ' + token,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
@@ -361,11 +300,6 @@ async function createConfig() {
|
||||
})
|
||||
});
|
||||
|
||||
if (resp.status === 401) {
|
||||
logout();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json();
|
||||
alert('Ошибка: ' + (err.error || 'Не удалось создать'));
|
||||
@@ -421,11 +355,6 @@ document.getElementById('clone-input').addEventListener('keydown', function(e) {
|
||||
}
|
||||
});
|
||||
|
||||
// Pagination functions
|
||||
let currentPage = 1;
|
||||
let totalPages = 1;
|
||||
let perPage = 20;
|
||||
|
||||
function prevPage() {
|
||||
if (currentPage > 1) {
|
||||
currentPage--;
|
||||
@@ -451,27 +380,12 @@ function updatePagination(total) {
|
||||
|
||||
// Load configs with pagination
|
||||
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?page=' + currentPage + '&per_page=' + perPage, {
|
||||
headers: {'Authorization': 'Bearer ' + token}
|
||||
});
|
||||
const resp = await fetch('/api/configs?page=' + currentPage + '&per_page=' + perPage);
|
||||
|
||||
if (resp.status === 401) {
|
||||
logout();
|
||||
return;
|
||||
}
|
||||
|
||||
if (resp.status === 403) {
|
||||
if (!resp.ok) {
|
||||
document.getElementById('configs-list').innerHTML =
|
||||
'<div class="bg-white rounded-lg shadow p-8 text-center text-red-600">Нет доступа</div>';
|
||||
'<div class="bg-white rounded-lg shadow p-8 text-center text-red-600">Ошибка загрузки</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -269,9 +269,8 @@ async function loadCategoriesFromAPI() {
|
||||
|
||||
// Initialize
|
||||
document.addEventListener('DOMContentLoaded', async function() {
|
||||
const token = localStorage.getItem('token');
|
||||
|
||||
if (!token || !configUUID) {
|
||||
// RBAC disabled - no token check required
|
||||
if (!configUUID) {
|
||||
window.location.href = '/configs';
|
||||
return;
|
||||
}
|
||||
@@ -280,16 +279,9 @@ document.addEventListener('DOMContentLoaded', async function() {
|
||||
await loadCategoriesFromAPI();
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/configs/' + configUUID, {
|
||||
headers: {'Authorization': 'Bearer ' + token}
|
||||
});
|
||||
const resp = await fetch('/api/configs/' + configUUID);
|
||||
|
||||
if (resp.status === 401) {
|
||||
window.location.href = '/login';
|
||||
return;
|
||||
}
|
||||
|
||||
if (resp.status === 403 || resp.status === 404) {
|
||||
if (resp.status === 404) {
|
||||
showToast('Конфигурация не найдена', 'error');
|
||||
window.location.href = '/configs';
|
||||
return;
|
||||
@@ -1119,8 +1111,8 @@ function triggerAutoSave() {
|
||||
}
|
||||
|
||||
async function saveConfig(showNotification = true) {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token || !configUUID) return;
|
||||
// RBAC disabled - no token check required
|
||||
if (!configUUID) return;
|
||||
|
||||
// Get custom price if set
|
||||
const customPriceInput = document.getElementById('custom-price-input');
|
||||
@@ -1134,7 +1126,6 @@ async function saveConfig(showNotification = true) {
|
||||
const resp = await fetch('/api/configs/' + configUUID, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Authorization': 'Bearer ' + token,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
@@ -1146,11 +1137,6 @@ async function saveConfig(showNotification = true) {
|
||||
})
|
||||
});
|
||||
|
||||
if (resp.status === 401) {
|
||||
window.location.href = '/login';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!resp.ok) {
|
||||
if (showNotification) {
|
||||
showToast('Ошибка сохранения', 'error');
|
||||
@@ -1308,23 +1294,17 @@ async function exportCSVWithCustomPrice() {
|
||||
}
|
||||
|
||||
async function refreshPrices() {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token || !configUUID) return;
|
||||
// RBAC disabled - no token check required
|
||||
if (!configUUID) return;
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/configs/' + configUUID + '/refresh-prices', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': 'Bearer ' + token,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (resp.status === 401) {
|
||||
window.location.href = '/login';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!resp.ok) {
|
||||
showToast('Ошибка обновления цен', 'error');
|
||||
return;
|
||||
|
||||
270
web/templates/pricelist_detail.html
Normal file
270
web/templates/pricelist_detail.html
Normal file
@@ -0,0 +1,270 @@
|
||||
{{define "title"}}Прайслист - QuoteForge{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center space-x-4">
|
||||
<a href="/pricelists" class="text-gray-500 hover:text-gray-700">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path>
|
||||
</svg>
|
||||
</a>
|
||||
<h1 id="page-title" class="text-2xl font-bold text-gray-900">Загрузка...</h1>
|
||||
</div>
|
||||
|
||||
<div id="pricelist-info" class="bg-white rounded-lg shadow p-6">
|
||||
<div id="pl-notification" class="hidden mb-4 p-3 bg-yellow-50 border border-yellow-200 rounded-lg text-yellow-800"></div>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<p class="text-sm text-gray-500">Версия</p>
|
||||
<p id="pl-version" class="font-mono">-</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-gray-500">Дата создания</p>
|
||||
<p id="pl-date">-</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-gray-500">Автор</p>
|
||||
<p id="pl-author">-</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-gray-500">Позиций</p>
|
||||
<p id="pl-items">-</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-gray-500">Использований</p>
|
||||
<p id="pl-usage">-</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-gray-500">Статус</p>
|
||||
<p id="pl-status">-</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-gray-500">Истекает</p>
|
||||
<p id="pl-expires">-</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-lg shadow overflow-hidden">
|
||||
<div class="p-4 border-b">
|
||||
<input type="text" id="search-input" placeholder="Поиск по артикулу..."
|
||||
class="w-full md:w-64 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
</div>
|
||||
|
||||
<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-right 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>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="items-body" class="bg-white divide-y divide-gray-200">
|
||||
<tr>
|
||||
<td colspan="5" class="px-6 py-4 text-center text-gray-500">Загрузка...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div id="items-pagination" class="p-4 border-t flex justify-between items-center">
|
||||
<span id="items-info" class="text-sm text-gray-500"></span>
|
||||
<div id="items-pages" class="space-x-2"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const pricelistId = window.location.pathname.split('/').pop();
|
||||
let currentPage = 1;
|
||||
let searchQuery = '';
|
||||
let searchTimeout = null;
|
||||
|
||||
async function loadPricelistInfo() {
|
||||
try {
|
||||
const resp = await fetch(`/api/pricelists/${pricelistId}`);
|
||||
if (!resp.ok) throw new Error('Pricelist not found');
|
||||
|
||||
const pl = await resp.json();
|
||||
|
||||
document.getElementById('page-title').textContent = `Прайслист ${pl.version}`;
|
||||
document.getElementById('pl-version').textContent = pl.version;
|
||||
document.getElementById('pl-date').textContent = new Date(pl.created_at).toLocaleDateString('ru-RU');
|
||||
document.getElementById('pl-author').textContent = pl.created_by || '-';
|
||||
document.getElementById('pl-items').textContent = pl.item_count;
|
||||
document.getElementById('pl-usage').textContent = pl.usage_count;
|
||||
|
||||
// Show notification if present and pricelist is active
|
||||
const notificationEl = document.getElementById('pl-notification');
|
||||
if (pl.notification && pl.is_active) {
|
||||
notificationEl.textContent = pl.notification;
|
||||
notificationEl.classList.remove('hidden');
|
||||
} else {
|
||||
notificationEl.classList.add('hidden');
|
||||
}
|
||||
|
||||
const statusClass = pl.is_active ? 'text-green-600' : 'text-gray-600';
|
||||
document.getElementById('pl-status').innerHTML = `<span class="${statusClass}">${pl.is_active ? 'Активен' : 'Неактивен'}</span>`;
|
||||
|
||||
if (pl.expires_at) {
|
||||
document.getElementById('pl-expires').textContent = new Date(pl.expires_at).toLocaleDateString('ru-RU');
|
||||
} else {
|
||||
document.getElementById('pl-expires').textContent = '-';
|
||||
}
|
||||
} catch (e) {
|
||||
document.getElementById('page-title').textContent = 'Ошибка';
|
||||
showToast('Прайслист не найден', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadItems(page = 1) {
|
||||
currentPage = page;
|
||||
try {
|
||||
let url = `/api/pricelists/${pricelistId}/items?page=${page}&per_page=50`;
|
||||
if (searchQuery) {
|
||||
url += `&search=${encodeURIComponent(searchQuery)}`;
|
||||
}
|
||||
|
||||
const resp = await fetch(url);
|
||||
const data = await resp.json();
|
||||
|
||||
renderItems(data.items || []);
|
||||
renderItemsPagination(data.total, data.page, data.per_page);
|
||||
} catch (e) {
|
||||
document.getElementById('items-body').innerHTML = `
|
||||
<tr>
|
||||
<td colspan="5" class="px-6 py-4 text-center text-red-500">
|
||||
Ошибка загрузки: ${e.message}
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
function formatPriceSettings(item) {
|
||||
// Format price settings to match admin pricing interface style
|
||||
let settings = [];
|
||||
const hasManualPrice = item.manual_price && item.manual_price > 0;
|
||||
const hasMeta = item.meta_prices && item.meta_prices.trim() !== '';
|
||||
|
||||
// Method indicator
|
||||
if (hasManualPrice) {
|
||||
settings.push('<span class="text-orange-600 font-medium">РУЧН</span>');
|
||||
} else if (item.price_method === 'average') {
|
||||
settings.push('Сред');
|
||||
} else {
|
||||
settings.push('Мед');
|
||||
}
|
||||
|
||||
// Period (only if not manual price)
|
||||
if (!hasManualPrice) {
|
||||
const period = item.price_period_days !== undefined && item.price_period_days !== null ? item.price_period_days : 90;
|
||||
if (period === 7) settings.push('1н');
|
||||
else if (period === 30) settings.push('1м');
|
||||
else if (period === 90) settings.push('3м');
|
||||
else if (period === 365) settings.push('1г');
|
||||
else if (period === 0) settings.push('все');
|
||||
else settings.push(period + 'д');
|
||||
}
|
||||
|
||||
// Coefficient
|
||||
if (item.price_coefficient && item.price_coefficient !== 0) {
|
||||
settings.push((item.price_coefficient > 0 ? '+' : '') + item.price_coefficient + '%');
|
||||
}
|
||||
|
||||
// Meta article indicator
|
||||
if (hasMeta) {
|
||||
settings.push('<span class="text-purple-600 font-medium">МЕТА</span>');
|
||||
}
|
||||
|
||||
return settings.join(' | ') || '-';
|
||||
}
|
||||
|
||||
function renderItems(items) {
|
||||
if (items.length === 0) {
|
||||
document.getElementById('items-body').innerHTML = `
|
||||
<tr>
|
||||
<td colspan="5" class="px-6 py-4 text-center text-gray-500">
|
||||
${searchQuery ? 'Ничего не найдено' : 'Позиции не найдены'}
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
const html = items.map(item => {
|
||||
const price = item.price.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
const description = item.lot_description || '-';
|
||||
const truncatedDesc = description.length > 60 ? description.substring(0, 60) + '...' : description;
|
||||
|
||||
return `
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="px-6 py-3 whitespace-nowrap">
|
||||
<span class="font-mono text-sm">${item.lot_name}</span>
|
||||
</td>
|
||||
<td class="px-6 py-3 whitespace-nowrap">
|
||||
<span class="px-2 py-1 text-xs bg-gray-100 rounded">${item.category || '-'}</span>
|
||||
</td>
|
||||
<td class="px-6 py-3 text-sm text-gray-500" title="${description}">${truncatedDesc}</td>
|
||||
<td class="px-6 py-3 whitespace-nowrap text-right font-mono">${price}</td>
|
||||
<td class="px-6 py-3 whitespace-nowrap text-sm"><span class="text-xs bg-gray-100 px-2 py-1 rounded">${formatPriceSettings(item)}</span></td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
document.getElementById('items-body').innerHTML = html;
|
||||
}
|
||||
|
||||
function renderItemsPagination(total, page, perPage) {
|
||||
const totalPages = Math.ceil(total / perPage);
|
||||
const start = (page - 1) * perPage + 1;
|
||||
const end = Math.min(page * perPage, total);
|
||||
|
||||
document.getElementById('items-info').textContent = `${start}-${end} из ${total}`;
|
||||
|
||||
if (totalPages <= 1) {
|
||||
document.getElementById('items-pages').innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
|
||||
// Previous button
|
||||
if (page > 1) {
|
||||
html += `<button onclick="loadItems(${page - 1})" class="px-2 py-1 text-sm text-gray-600 hover:text-gray-900">←</button>`;
|
||||
}
|
||||
|
||||
// Page numbers (show max 5 pages)
|
||||
const startPage = Math.max(1, page - 2);
|
||||
const endPage = Math.min(totalPages, startPage + 4);
|
||||
|
||||
for (let i = startPage; i <= endPage; i++) {
|
||||
const activeClass = i === page ? 'bg-blue-600 text-white' : 'bg-white text-gray-700 hover:bg-gray-50';
|
||||
html += `<button onclick="loadItems(${i})" class="px-3 py-1 text-sm rounded border ${activeClass}">${i}</button>`;
|
||||
}
|
||||
|
||||
// Next button
|
||||
if (page < totalPages) {
|
||||
html += `<button onclick="loadItems(${page + 1})" class="px-2 py-1 text-sm text-gray-600 hover:text-gray-900">→</button>`;
|
||||
}
|
||||
|
||||
document.getElementById('items-pages').innerHTML = html;
|
||||
}
|
||||
|
||||
document.getElementById('search-input').addEventListener('input', function(e) {
|
||||
clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(() => {
|
||||
searchQuery = e.target.value.trim();
|
||||
loadItems(1);
|
||||
}, 300);
|
||||
});
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
loadPricelistInfo();
|
||||
loadItems(1);
|
||||
});
|
||||
</script>
|
||||
{{end}}
|
||||
|
||||
{{template "base" .}}
|
||||
234
web/templates/pricelists.html
Normal file
234
web/templates/pricelists.html
Normal file
@@ -0,0 +1,234 @@
|
||||
{{define "title"}}Прайслисты - QuoteForge{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<div class="space-y-6">
|
||||
<div class="flex justify-between items-center">
|
||||
<h1 class="text-2xl font-bold text-gray-900">Прайслисты</h1>
|
||||
<div id="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="pagination" class="flex justify-center space-x-2"></div>
|
||||
</div>
|
||||
|
||||
<!-- Create Modal -->
|
||||
<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 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="db-username" class="font-medium">загрузка...</span>
|
||||
</p>
|
||||
<form id="create-form" class="space-y-4">
|
||||
<div class="flex justify-end space-x-3">
|
||||
<button type="button" onclick="closeCreateModal()"
|
||||
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>
|
||||
|
||||
<script>
|
||||
let canWrite = false;
|
||||
let currentPage = 1;
|
||||
|
||||
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('create-btn-container').innerHTML = `
|
||||
<button onclick="openCreateModal()" 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) {
|
||||
currentPage = page;
|
||||
try {
|
||||
const resp = await fetch(`/api/pricelists?page=${page}&per_page=20`);
|
||||
const data = await resp.json();
|
||||
|
||||
renderPricelists(data.pricelists || []);
|
||||
renderPagination(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 renderPagination(total, page, perPage) {
|
||||
const totalPages = Math.ceil(total / perPage);
|
||||
if (totalPages <= 1) {
|
||||
document.getElementById('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('pagination').innerHTML = html;
|
||||
}
|
||||
|
||||
async function loadDbUsername() {
|
||||
try {
|
||||
const resp = await fetch('/api/current-user');
|
||||
const data = await resp.json();
|
||||
document.getElementById('db-username').textContent = data.username || 'неизвестно';
|
||||
} catch (e) {
|
||||
document.getElementById('db-username').textContent = 'неизвестно';
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateModal() {
|
||||
document.getElementById('create-modal').classList.remove('hidden');
|
||||
document.getElementById('create-modal').classList.add('flex');
|
||||
loadDbUsername();
|
||||
}
|
||||
|
||||
function closeCreateModal() {
|
||||
document.getElementById('create-modal').classList.add('hidden');
|
||||
document.getElementById('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(currentPage);
|
||||
} catch (e) {
|
||||
showToast('Ошибка: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('create-form').addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
try {
|
||||
const pl = await createPricelist();
|
||||
closeCreateModal();
|
||||
showToast(`Прайслист ${pl.version} создан (${pl.item_count} позиций)`, 'success');
|
||||
loadPricelists(1);
|
||||
} catch (e) {
|
||||
showToast('Ошибка: ' + e.message, 'error');
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
checkPricelistWritePermission();
|
||||
loadPricelists(1);
|
||||
});
|
||||
</script>
|
||||
{{end}}
|
||||
|
||||
{{template "base" .}}
|
||||
153
web/templates/setup.html
Normal file
153
web/templates/setup.html
Normal file
@@ -0,0 +1,153 @@
|
||||
{{define "setup.html"}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>QuoteForge - Настройка подключения</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
</head>
|
||||
<body class="bg-gray-100 min-h-screen flex items-center justify-center">
|
||||
<div class="max-w-md w-full mx-4">
|
||||
<div class="bg-white rounded-lg shadow-lg p-8">
|
||||
<div class="text-center mb-8">
|
||||
<h1 class="text-2xl font-bold text-blue-600">QuoteForge</h1>
|
||||
<p class="text-gray-600 mt-2">Настройка подключения к базе данных</p>
|
||||
</div>
|
||||
|
||||
<form id="setup-form" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Хост сервера</label>
|
||||
<input type="text" name="host" id="host"
|
||||
value="{{if .Settings}}{{.Settings.Host}}{{else}}localhost{{end}}"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="localhost или IP-адрес">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Порт</label>
|
||||
<input type="number" name="port" id="port"
|
||||
value="{{if .Settings}}{{.Settings.Port}}{{else}}3306{{end}}"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="3306">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">База данных</label>
|
||||
<input type="text" name="database" id="database"
|
||||
value="{{if .Settings}}{{.Settings.Database}}{{else}}RFQ_LOG{{end}}"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="RFQ_LOG">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Пользователь</label>
|
||||
<input type="text" name="user" id="user"
|
||||
value="{{if .Settings}}{{.Settings.User}}{{end}}"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="username">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Пароль</label>
|
||||
<input type="password" name="password" id="password"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="{{if .Settings}}********{{else}}password{{end}}">
|
||||
{{if .Settings}}
|
||||
<p class="text-xs text-gray-500 mt-1">Оставьте пустым, чтобы сохранить текущий пароль</p>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<div id="status" class="hidden p-3 rounded-md text-sm"></div>
|
||||
|
||||
<div class="flex space-x-3 pt-4">
|
||||
<button type="button" onclick="testConnection()"
|
||||
class="flex-1 px-4 py-2 bg-gray-100 text-gray-700 rounded-md hover:bg-gray-200 transition">
|
||||
Проверить
|
||||
</button>
|
||||
<button type="submit"
|
||||
class="flex-1 px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition">
|
||||
Сохранить
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<p class="text-center text-gray-500 text-sm mt-4">
|
||||
QuoteForge v1.0 - Конфигуратор серверов
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function showStatus(message, type) {
|
||||
const status = document.getElementById('status');
|
||||
status.classList.remove('hidden', 'bg-green-100', 'text-green-800', 'bg-red-100', 'text-red-800', 'bg-blue-100', 'text-blue-800');
|
||||
|
||||
if (type === 'success') {
|
||||
status.classList.add('bg-green-100', 'text-green-800');
|
||||
} else if (type === 'error') {
|
||||
status.classList.add('bg-red-100', 'text-red-800');
|
||||
} else {
|
||||
status.classList.add('bg-blue-100', 'text-blue-800');
|
||||
}
|
||||
|
||||
status.textContent = message;
|
||||
}
|
||||
|
||||
async function testConnection() {
|
||||
showStatus('Проверка подключения...', 'info');
|
||||
|
||||
const formData = new FormData(document.getElementById('setup-form'));
|
||||
|
||||
try {
|
||||
const resp = await fetch('/setup/test', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
const data = await resp.json();
|
||||
|
||||
if (data.success) {
|
||||
let msg = data.message;
|
||||
if (data.can_write) {
|
||||
msg += ' Права на запись: есть.';
|
||||
} else {
|
||||
msg += ' Права на запись: только чтение.';
|
||||
}
|
||||
showStatus(msg, 'success');
|
||||
} else {
|
||||
showStatus(data.error, 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
showStatus('Ошибка сети: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('setup-form').addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
showStatus('Сохранение настроек...', 'info');
|
||||
|
||||
const formData = new FormData(this);
|
||||
|
||||
try {
|
||||
const resp = await fetch('/setup', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
const data = await resp.json();
|
||||
|
||||
if (data.success) {
|
||||
showStatus(data.message + ' Перенаправление...', 'success');
|
||||
setTimeout(() => {
|
||||
window.location.href = '/';
|
||||
}, 2000);
|
||||
} else {
|
||||
showStatus(data.error, 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
showStatus('Ошибка сети: ' + e.message, 'error');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
Reference in New Issue
Block a user