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