Files
QuoteForge/web/templates/base.html
Michael Chus 143d217397 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>
2026-02-01 11:00:32 +03:00

173 lines
7.7 KiB
HTML

{{define "base"}}
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{template "title" .}}</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<style>
.htmx-request { opacity: 0.5; }
.line-clamp-2 { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
</style>
</head>
<body class="bg-gray-100 min-h-screen">
<nav class="bg-white shadow-sm sticky top-0 z-40">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-14">
<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="/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 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>
</nav>
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 pb-12">
{{template "content" .}}
</main>
<div id="toast" class="fixed bottom-4 right-4 z-50"></div>
<footer class="fixed bottom-0 left-0 right-0 bg-gray-800 text-gray-300 text-xs py-1 px-4">
<div class="max-w-7xl mx-auto flex justify-between">
<span id="db-status">БД: проверка...</span>
<span id="db-counts"></span>
</div>
</footer>
<script>
function showToast(msg, type) {
const colors = { success: 'bg-green-500', error: 'bg-red-500', info: 'bg-blue-500' };
const el = document.getElementById('toast');
el.innerHTML = '<div class="' + (colors[type] || colors.info) + ' text-white px-4 py-2 rounded shadow">' + msg + '</div>';
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>';
}
countsEl.textContent = 'lot: ' + data.lot_count + ' | lot_log: ' + data.lot_log_count + ' | metadata: ' + data.metadata_count;
} catch(e) {
document.getElementById('db-status').innerHTML = '<span class="text-red-400">БД: нет связи</span>';
}
}
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() {
checkDbStatus();
checkWritePermission();
checkSyncStatus();
// Auto-refresh sync status every 30 seconds
setInterval(checkSyncStatus, 30000);
});
</script>
</body>
</html>
{{end}}