**Changes:** 1. **Admin menu always visible** (base.html) - Removed 'hidden' class from "Администратор цен" link - Menu no longer depends on write permission check - Users can access pricing/pricelists pages in offline mode 2. **Online status checks for mutations** (admin_pricing.html) - Added checkOnlineStatus() helper function - createPricelist() checks online before creating - deletePricelist() checks online before deleting - Clear user feedback when operations blocked offline **User Impact:** - Admin menu accessible in both online and offline modes - View-only access to pricelists when offline - Clear error messages when attempting mutations offline - Better offline-first UX Part of Phase 2.5: Full Offline Mode Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
274 lines
12 KiB
HTML
274 lines
12 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="/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">Администратор цен</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 (htmx-powered) -->
|
||
<div id="sync-status"
|
||
class="flex items-center gap-3 text-sm"
|
||
hx-get="/partials/sync-status"
|
||
hx-trigger="load, refresh from:body, every 30s"
|
||
hx-swap="innerHTML">
|
||
</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>
|
||
|
||
<!-- Sync Info Modal -->
|
||
<div id="sync-modal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 hidden">
|
||
<div class="bg-white rounded-lg shadow-xl max-w-md w-full mx-4">
|
||
<div class="p-6">
|
||
<div class="flex justify-between items-center mb-4">
|
||
<h3 class="text-lg font-medium text-gray-900">Информация о синхронизации</h3>
|
||
<button onclick="closeSyncModal()" class="text-gray-400 hover:text-gray-500">
|
||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
|
||
<div class="space-y-4">
|
||
<div>
|
||
<h4 class="font-medium text-gray-900">Статус БД</h4>
|
||
<p id="modal-db-status" class="text-sm text-gray-600">Проверка...</p>
|
||
</div>
|
||
|
||
<div>
|
||
<h4 class="font-medium text-gray-900">Количество ошибок</h4>
|
||
<p id="modal-error-count" class="text-sm text-gray-600">0</p>
|
||
</div>
|
||
|
||
<div>
|
||
<h4 class="font-medium text-gray-900">Последняя синхронизация</h4>
|
||
<p id="modal-last-sync" class="text-sm text-gray-600">-</p>
|
||
</div>
|
||
|
||
<div>
|
||
<h4 class="font-medium text-gray-900">Список ошибок</h4>
|
||
<div id="modal-errors-list" class="mt-2 text-sm text-gray-600 max-h-40 overflow-y-auto">
|
||
<p>Нет ошибок</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="mt-6 flex justify-end">
|
||
<button onclick="closeSyncModal()" class="px-4 py-2 bg-gray-600 text-white rounded-md hover:bg-gray-700">
|
||
Закрыть
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</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);
|
||
}
|
||
|
||
// Open sync modal
|
||
function openSyncModal() {
|
||
const modal = document.getElementById('sync-modal');
|
||
if (modal) {
|
||
modal.classList.remove('hidden');
|
||
// Load sync info when modal opens
|
||
loadSyncInfo();
|
||
}
|
||
}
|
||
|
||
// Close sync modal
|
||
function closeSyncModal() {
|
||
const modal = document.getElementById('sync-modal');
|
||
if (modal) {
|
||
modal.classList.add('hidden');
|
||
}
|
||
}
|
||
|
||
// Load sync info for modal
|
||
async function loadSyncInfo() {
|
||
try {
|
||
const resp = await fetch('/api/sync/info');
|
||
const data = await resp.json();
|
||
|
||
document.getElementById('modal-db-status').textContent = data.is_online ? 'Подключено' : 'Отключено';
|
||
document.getElementById('modal-error-count').textContent = data.error_count;
|
||
|
||
if (data.last_sync_at) {
|
||
const date = new Date(data.last_sync_at);
|
||
document.getElementById('modal-last-sync').textContent = date.toLocaleString('ru-RU');
|
||
} else {
|
||
document.getElementById('modal-last-sync').textContent = 'Нет данных';
|
||
}
|
||
|
||
// Load error list
|
||
const errorsList = document.getElementById('modal-errors-list');
|
||
if (data.errors && data.errors.length > 0) {
|
||
errorsList.innerHTML = data.errors.map(error =>
|
||
`<div class="mb-1"><span class="font-medium">${error.timestamp}</span>: ${error.message}</div>`
|
||
).join('');
|
||
} else {
|
||
errorsList.innerHTML = '<p>Нет ошибок</p>';
|
||
}
|
||
} catch(e) {
|
||
console.error('Failed to load sync info:', e);
|
||
document.getElementById('modal-db-status').textContent = 'Ошибка загрузки';
|
||
document.getElementById('modal-error-count').textContent = '0';
|
||
document.getElementById('modal-last-sync').textContent = '-';
|
||
document.getElementById('modal-errors-list').innerHTML = '<p>Ошибка загрузки данных</p>';
|
||
}
|
||
}
|
||
|
||
// Event delegation for sync dropdown and actions
|
||
document.addEventListener('DOMContentLoaded', function() {
|
||
checkDbStatus();
|
||
checkWritePermission();
|
||
});
|
||
|
||
// Event delegation for sync actions
|
||
document.body.addEventListener('click', function(e) {
|
||
// Handle sync button click (full sync only)
|
||
const syncButton = e.target.closest('#sync-button');
|
||
if (syncButton) {
|
||
e.stopPropagation();
|
||
const button = syncButton;
|
||
|
||
// Add loading state
|
||
const originalHTML = button.innerHTML;
|
||
button.disabled = true;
|
||
button.innerHTML = '<svg class="animate-spin w-4 h-4 inline mr-2" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg>';
|
||
|
||
fullSync(button, originalHTML);
|
||
}
|
||
});
|
||
|
||
// Refactored sync action function to reduce duplication
|
||
async function syncAction(endpoint, successMessage, button, originalHTML) {
|
||
try {
|
||
const resp = await fetch(endpoint, { method: 'POST' });
|
||
const data = await resp.json();
|
||
|
||
if (data.success) {
|
||
showToast(successMessage, 'success');
|
||
// Update last sync time - removed since dropdown is gone
|
||
// loadLastSyncTime();
|
||
} else {
|
||
showToast('Ошибка: ' + (data.error || 'неизвестная ошибка'), 'error');
|
||
}
|
||
|
||
htmx.trigger('#sync-status', 'refresh');
|
||
} catch (error) {
|
||
showToast('Ошибка: ' + error.message, 'error');
|
||
} finally {
|
||
// Reset button state
|
||
if (button) {
|
||
button.disabled = false;
|
||
button.innerHTML = originalHTML;
|
||
}
|
||
}
|
||
}
|
||
|
||
function pushPendingChanges(button, originalHTML) {
|
||
syncAction('/api/sync/push', 'Изменения синхронизированы', button, originalHTML);
|
||
}
|
||
|
||
function fullSync(button, originalHTML) {
|
||
syncAction('/api/sync/all', 'Полная синхронизация завершена', button, originalHTML);
|
||
}
|
||
|
||
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>';
|
||
}
|
||
}
|
||
|
||
// Admin pricing link is now always visible
|
||
// Write permission is checked at operation time (create/delete)
|
||
async function checkWritePermission() {
|
||
// No longer needed - link always visible in offline-first mode
|
||
// Operations will check online status when executed
|
||
}
|
||
|
||
// Load last sync time for dropdown (removed since dropdown is gone)
|
||
// async function loadLastSyncTime() {
|
||
// try {
|
||
// const resp = await fetch('/api/sync/status');
|
||
// const data = await resp.json();
|
||
// if (data.last_pricelist_sync) {
|
||
// const date = new Date(data.last_pricelist_sync);
|
||
// document.getElementById('last-sync-time').textContent = date.toLocaleString('ru-RU');
|
||
// } else {
|
||
// document.getElementById('last-sync-time').textContent = 'Нет данных';
|
||
// }
|
||
// } catch(e) {
|
||
// console.error('Failed to load last sync time:', e);
|
||
// }
|
||
// }
|
||
|
||
// Call functions immediately to ensure they run even before DOMContentLoaded
|
||
// This ensures username and admin link are visible ASAP
|
||
checkDbStatus();
|
||
checkWritePermission();
|
||
|
||
// Load last sync time - removed since dropdown is gone
|
||
// document.addEventListener('DOMContentLoaded', loadLastSyncTime);
|
||
</script>
|
||
</body>
|
||
</html>
|
||
{{end}}
|