Root cause: Projects with duplicate (code, variant) pairs fail to sync due to unique constraint on server. Example: multiple "OPS-1934" projects with variant="Dell" where one already exists on server. Fixes: 1. Sync service now detects duplicate (code, variant) on server and links local project to existing server project instead of failing 2. Local repair checks for duplicate (code, variant) pairs and deduplicates by appending UUID suffix to variant 3. Modal now scrollable with fixed header/footer (max-h-90vh) This allows users to sync projects that were created offline with conflicting codes/variants without losing data. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
450 lines
24 KiB
HTML
450 lines
24 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="/projects" class="text-gray-600 hover:text-gray-900 px-3 py-2 text-sm">Мои проекты</a>
|
||
<a href="/pricelists" 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">
|
||
{{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 p-4">
|
||
<div class="bg-white rounded-lg shadow-xl max-w-lg w-full max-h-[90vh] flex flex-col">
|
||
<div class="p-6 border-b border-gray-200 flex-shrink-0">
|
||
<div class="flex justify-between items-center">
|
||
<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>
|
||
|
||
<div class="overflow-y-auto flex-1">
|
||
<div class="p-6 space-y-5">
|
||
<!-- Section 1: DB Connection -->
|
||
<div>
|
||
<h4 class="font-medium text-gray-900 mb-2">Подключение к БД</h4>
|
||
<div class="text-sm space-y-1">
|
||
<div class="flex justify-between">
|
||
<span class="text-gray-500">Адрес:</span>
|
||
<span id="modal-db-host" class="text-gray-700 font-mono">—</span>
|
||
</div>
|
||
<div class="flex justify-between">
|
||
<span class="text-gray-500">Пользователь:</span>
|
||
<span id="modal-db-user" class="text-gray-700">—</span>
|
||
</div>
|
||
<div class="flex justify-between">
|
||
<span class="text-gray-500">Статус:</span>
|
||
<span id="modal-db-status" class="text-gray-700">Проверка...</span>
|
||
</div>
|
||
<div class="flex justify-between">
|
||
<span class="text-gray-500">Последняя синхронизация:</span>
|
||
<span id="modal-last-sync" class="text-gray-700">—</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="modal-readiness-section" class="hidden">
|
||
<h4 class="font-medium text-red-700 mb-2">Почему синхронизация недоступна</h4>
|
||
<div class="bg-red-50 border border-red-200 rounded px-3 py-2 text-sm">
|
||
<div id="modal-readiness-reason" class="text-red-700">—</div>
|
||
<div id="modal-readiness-min-version" class="text-red-600 text-xs mt-1 hidden"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Section 2: Statistics -->
|
||
<div>
|
||
<h4 class="font-medium text-gray-900 mb-2">Статистика</h4>
|
||
<div class="grid grid-cols-2 gap-2 text-sm">
|
||
<div class="flex justify-between bg-gray-50 rounded px-3 py-1.5">
|
||
<span class="text-gray-500">Компоненты (lot):</span>
|
||
<span id="modal-lot-count" class="font-medium text-gray-700">—</span>
|
||
</div>
|
||
<div class="flex justify-between bg-gray-50 rounded px-3 py-1.5">
|
||
<span class="text-gray-500">Котировки:</span>
|
||
<span id="modal-lotlog-count" class="font-medium text-gray-700">—</span>
|
||
</div>
|
||
<div class="flex justify-between bg-gray-50 rounded px-3 py-1.5">
|
||
<span class="text-gray-500">Конфигурации:</span>
|
||
<span id="modal-config-count" class="font-medium text-gray-700">—</span>
|
||
</div>
|
||
<div class="flex justify-between bg-gray-50 rounded px-3 py-1.5">
|
||
<span class="text-gray-500">Проекты:</span>
|
||
<span id="modal-project-count" class="font-medium text-gray-700">—</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Section 3: Pending Changes (shown only if any) -->
|
||
<div id="modal-pending-section" class="hidden">
|
||
<h4 class="font-medium text-gray-900 mb-2">Ожидающие синхронизации</h4>
|
||
<div id="modal-pending-list" class="text-sm max-h-40 overflow-y-auto space-y-1"></div>
|
||
</div>
|
||
|
||
<!-- Section 4: Errors (shown only if any) -->
|
||
<div id="modal-errors-section" class="hidden">
|
||
<h4 class="font-medium text-gray-900 mb-2">Ошибки синхронизации</h4>
|
||
<div id="modal-errors-list" class="text-sm max-h-40 overflow-y-auto space-y-1"></div>
|
||
</div>
|
||
|
||
<!-- Section 5: Self-Healing (shown only if errors exist) -->
|
||
<div id="modal-repair-section" class="hidden">
|
||
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||
<h4 class="font-medium text-blue-900 mb-2">Автоматическое исправление</h4>
|
||
<p class="text-sm text-blue-700 mb-3">
|
||
Система может исправить данные и очистить ошибки синхронизации:
|
||
</p>
|
||
<ul class="text-sm text-blue-700 mb-3 ml-4 list-disc space-y-1">
|
||
<li>Проверит и исправит названия проектов</li>
|
||
<li>Восстановит битые ссылки на проекты</li>
|
||
<li>Очистит ошибки и даст pending changes еще шанс</li>
|
||
</ul>
|
||
<button id="repair-button" onclick="repairPendingChanges()"
|
||
class="w-full px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed">
|
||
ИСПРАВИТЬ
|
||
</button>
|
||
<div id="repair-result" class="mt-2 text-sm hidden"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="p-6 border-t border-gray-200 flex-shrink-0">
|
||
<div class="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>
|
||
|
||
<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();
|
||
|
||
// Section 1: DB Connection
|
||
document.getElementById('modal-db-host').textContent = data.db_host ? data.db_host + '/' + data.db_name : '—';
|
||
document.getElementById('modal-db-user').textContent = data.db_user || '—';
|
||
|
||
const statusEl = document.getElementById('modal-db-status');
|
||
if (data.is_online) {
|
||
statusEl.innerHTML = '<span class="inline-block w-2 h-2 rounded-full bg-green-500 mr-1"></span>Online';
|
||
} else {
|
||
statusEl.innerHTML = '<span class="inline-block w-2 h-2 rounded-full bg-red-500 mr-1"></span>Offline';
|
||
}
|
||
|
||
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 = '—';
|
||
}
|
||
|
||
const readinessSection = document.getElementById('modal-readiness-section');
|
||
const readinessReason = document.getElementById('modal-readiness-reason');
|
||
const readinessMinVersion = document.getElementById('modal-readiness-min-version');
|
||
if (data.readiness && data.readiness.blocked) {
|
||
readinessSection.classList.remove('hidden');
|
||
readinessReason.textContent = data.readiness.reason_text || 'Синхронизация заблокирована preflight-проверкой.';
|
||
if (data.readiness.required_min_app_version) {
|
||
readinessMinVersion.classList.remove('hidden');
|
||
readinessMinVersion.textContent = 'Требуется обновление до версии ' + data.readiness.required_min_app_version;
|
||
} else {
|
||
readinessMinVersion.classList.add('hidden');
|
||
readinessMinVersion.textContent = '';
|
||
}
|
||
} else {
|
||
readinessSection.classList.add('hidden');
|
||
readinessReason.textContent = '';
|
||
readinessMinVersion.classList.add('hidden');
|
||
readinessMinVersion.textContent = '';
|
||
}
|
||
|
||
// Section 2: Statistics
|
||
document.getElementById('modal-lot-count').textContent = data.is_online ? data.lot_count.toLocaleString() : '—';
|
||
document.getElementById('modal-lotlog-count').textContent = data.is_online ? data.lot_log_count.toLocaleString() : '—';
|
||
document.getElementById('modal-config-count').textContent = data.config_count.toLocaleString();
|
||
document.getElementById('modal-project-count').textContent = data.project_count.toLocaleString();
|
||
|
||
// Section 3: Pending changes
|
||
const pendingSection = document.getElementById('modal-pending-section');
|
||
const pendingList = document.getElementById('modal-pending-list');
|
||
if (data.pending_changes && data.pending_changes.length > 0) {
|
||
pendingSection.classList.remove('hidden');
|
||
pendingList.innerHTML = data.pending_changes.map(ch => {
|
||
const shortUUID = ch.entity_uuid.substring(0, 8);
|
||
const time = new Date(ch.created_at).toLocaleString('ru-RU');
|
||
const hasError = ch.last_error ? ' border-l-2 border-red-400 pl-2' : '';
|
||
const errorLine = ch.last_error ? `<div class="text-red-500 text-xs mt-0.5">${ch.last_error}</div>` : '';
|
||
return `<div class="bg-gray-50 rounded px-3 py-1.5${hasError}">
|
||
<span class="font-medium">${ch.operation}</span>
|
||
<span class="text-gray-500">${ch.entity_type}</span>
|
||
<span class="font-mono text-xs text-gray-400">${shortUUID}</span>
|
||
<span class="text-gray-400 text-xs ml-1">${time}</span>
|
||
${errorLine}
|
||
</div>`;
|
||
}).join('');
|
||
} else {
|
||
pendingSection.classList.add('hidden');
|
||
}
|
||
|
||
// Section 4: Errors
|
||
const errorsSection = document.getElementById('modal-errors-section');
|
||
const errorsList = document.getElementById('modal-errors-list');
|
||
const hasErrors = data.errors && data.errors.length > 0;
|
||
if (hasErrors) {
|
||
errorsSection.classList.remove('hidden');
|
||
errorsList.innerHTML = data.errors.map(error => {
|
||
const time = new Date(error.timestamp).toLocaleString('ru-RU');
|
||
return `<div class="bg-red-50 text-red-700 rounded px-3 py-1.5">
|
||
<span class="text-xs text-red-400">${time}</span>: ${error.message}
|
||
</div>`;
|
||
}).join('');
|
||
} else {
|
||
errorsSection.classList.add('hidden');
|
||
}
|
||
|
||
// Section 5: Repair (show only if errors exist)
|
||
const repairSection = document.getElementById('modal-repair-section');
|
||
const repairResult = document.getElementById('repair-result');
|
||
if (hasErrors) {
|
||
repairSection.classList.remove('hidden');
|
||
repairResult.classList.add('hidden');
|
||
repairResult.innerHTML = '';
|
||
} else {
|
||
repairSection.classList.add('hidden');
|
||
}
|
||
} catch(e) {
|
||
console.error('Failed to load sync info:', e);
|
||
document.getElementById('modal-db-status').textContent = 'Ошибка загрузки';
|
||
}
|
||
}
|
||
|
||
// Repair pending changes
|
||
async function repairPendingChanges() {
|
||
const button = document.getElementById('repair-button');
|
||
const resultDiv = document.getElementById('repair-result');
|
||
|
||
button.disabled = true;
|
||
button.textContent = 'Исправление...';
|
||
resultDiv.classList.add('hidden');
|
||
|
||
try {
|
||
const resp = await fetch('/api/sync/repair', { method: 'POST' });
|
||
const data = await resp.json();
|
||
|
||
if (data.success) {
|
||
resultDiv.classList.remove('hidden');
|
||
if (data.repaired > 0) {
|
||
resultDiv.className = 'mt-2 text-sm text-green-700 bg-green-50 rounded px-3 py-2';
|
||
resultDiv.textContent = `✓ Исправлено: ${data.repaired}`;
|
||
// Reload sync info after repair
|
||
setTimeout(() => loadSyncInfo(), 1000);
|
||
} else {
|
||
resultDiv.className = 'mt-2 text-sm text-yellow-700 bg-yellow-50 rounded px-3 py-2';
|
||
resultDiv.textContent = 'Нечего исправлять или проблемы остались';
|
||
if (data.remaining_errors && data.remaining_errors.length > 0) {
|
||
resultDiv.innerHTML += '<div class="mt-1 text-xs">' + data.remaining_errors.join('<br>') + '</div>';
|
||
}
|
||
}
|
||
} else {
|
||
resultDiv.classList.remove('hidden');
|
||
resultDiv.className = 'mt-2 text-sm text-red-700 bg-red-50 rounded px-3 py-2';
|
||
resultDiv.textContent = 'Ошибка: ' + (data.error || 'неизвестная ошибка');
|
||
}
|
||
} catch (e) {
|
||
resultDiv.classList.remove('hidden');
|
||
resultDiv.className = 'mt-2 text-sm text-red-700 bg-red-50 rounded px-3 py-2';
|
||
resultDiv.textContent = 'Ошибка запроса: ' + e.message;
|
||
} finally {
|
||
button.disabled = false;
|
||
button.textContent = 'ИСПРАВИТЬ';
|
||
}
|
||
}
|
||
|
||
// Event delegation for sync dropdown and actions
|
||
document.addEventListener('DOMContentLoaded', function() {
|
||
loadDBUser();
|
||
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();
|
||
|
||
// Dispatch custom event for pages to react to sync completion
|
||
window.dispatchEvent(new CustomEvent('sync-completed', {
|
||
detail: {
|
||
endpoint: endpoint,
|
||
data: data
|
||
}
|
||
}));
|
||
} else if (resp.status === 423) {
|
||
const reason = data.reason_text || data.error || 'Синхронизация заблокирована.';
|
||
showToast(reason, 'error');
|
||
openSyncModal();
|
||
loadSyncInfo();
|
||
} 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 loadDBUser() {
|
||
try {
|
||
const resp = await fetch('/api/db-status');
|
||
const data = await resp.json();
|
||
const userEl = document.getElementById('db-user');
|
||
if (data.connected && data.db_user) {
|
||
userEl.innerHTML = '<span class="text-gray-500">@</span>' + data.db_user;
|
||
}
|
||
} catch(e) {
|
||
// ignore
|
||
}
|
||
}
|
||
|
||
// 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
|
||
loadDBUser();
|
||
checkWritePermission();
|
||
|
||
// Load last sync time - removed since dropdown is gone
|
||
// document.addEventListener('DOMContentLoaded', loadLastSyncTime);
|
||
</script>
|
||
</body>
|
||
</html>
|
||
{{end}}
|