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