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:
2026-02-01 11:00:32 +03:00
parent 8b8d2f18f9
commit 143d217397
30 changed files with 3697 additions and 887 deletions

View File

@@ -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();

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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;

View 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">&larr;</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">&rarr;</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" .}}

View 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
View 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}}