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

@@ -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" .}}