Files
QuoteForge/web/templates/base.html
Michael Chus 09d694234d feat: кнопка "Обновить цены" использует последний скачанный прайслист без синхронизации и показывает diff
- убрать вызовы /api/sync/components и /api/sync/pricelists из обеих кнопок
- брать самый свежий прайслист из уже скачанных (active_only)
- проверять галочку disable_price_refresh (пропускать конфиг если включена)
- показывать модальное окно diff: компонент / цена за шт. / сумма (было → стало) + итог конфиги
- общие утилиты (fetchLatestEstimatePricelistId, showPriceDiffModal) вынесены в base.html
- обе кнопки вызывают refreshPrices() без дублирования кода

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 04:26:25 +03:00

693 lines
37 KiB
HTML
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{{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>
<link rel="stylesheet" href="/static/app.css">
<script src="/static/vendor/tailwindcss.browser.js"></script>
<script src="/static/vendor/htmx-1.9.10.min.js"></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="/partnumber-books" 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>
<a href="/api/support-bundle"
title="Скачать Support Bundle"
class="text-gray-400 hover:text-gray-600 transition-colors"
download>
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>
</a>
</div>
</div>
</div>
</nav>
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
{{template "content" .}}
</main>
<footer class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-3 text-right">
<span class="text-xs text-gray-400">v{{.AppVersion}}</span>
</footer>
<div id="toast" class="fixed bottom-4 right-4 z-50"></div>
<!-- Dead-man's switch overlay: shown when backend process stops responding -->
<div id="backend-offline-overlay" class="hidden fixed inset-0 z-[9999] bg-black/80 flex items-center justify-center p-6">
<div class="bg-white rounded-xl shadow-2xl max-w-md w-full p-8 text-center">
<div class="text-red-500 mb-4">
<svg class="w-16 h-16 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/>
</svg>
</div>
<h2 class="text-xl font-bold text-gray-900 mb-3">Приложение остановлено</h2>
<p class="text-gray-600 mb-4">Консольное окно QuoteForge было закрыто — без него программа не работает.</p>
<div class="bg-amber-50 border border-amber-200 rounded-lg p-3 mb-6 text-sm text-amber-800">
Запустите программу заново и нажмите «Обновить страницу».
</div>
<button onclick="window.location.reload()" class="w-full px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium">
Обновить страницу
</button>
</div>
</div>
<!-- Price Diff Modal -->
<div id="price-diff-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 w-full max-w-2xl max-h-[90vh] flex flex-col">
<div class="p-5 border-b border-gray-200 flex-shrink-0 flex justify-between items-center">
<h3 class="text-lg font-semibold text-gray-900">Изменение цен</h3>
<button onclick="closePriceDiffModal()" class="text-gray-400 hover:text-gray-600">
<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="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<div id="price-diff-modal-body" class="overflow-y-auto flex-1 p-5 space-y-5"></div>
<div class="p-4 border-t border-gray-200 flex-shrink-0 flex justify-end">
<button onclick="closePriceDiffModal()" class="px-4 py-2 bg-gray-600 text-white rounded hover:bg-gray-700 text-sm">Закрыть</button>
</div>
</div>
</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>
<div id="modal-pricelist-sync-issue" 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 space-y-1">
<div id="modal-pricelist-sync-summary" class="text-red-700"></div>
<div id="modal-pricelist-sync-attempt" class="text-red-600 text-xs hidden"></div>
<div id="modal-pricelist-sync-error" class="text-red-600 text-xs hidden whitespace-pre-wrap"></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 = '';
}
const syncIssueSection = document.getElementById('modal-pricelist-sync-issue');
const syncIssueSummary = document.getElementById('modal-pricelist-sync-summary');
const syncIssueAttempt = document.getElementById('modal-pricelist-sync-attempt');
const syncIssueError = document.getElementById('modal-pricelist-sync-error');
const hasSyncFailure = data.last_pricelist_sync_status === 'failed';
if (data.has_incomplete_server_sync) {
syncIssueSection.classList.remove('hidden');
syncIssueSummary.textContent = 'Последняя синхронизация прайслистов прервалась. На сервере есть изменения, которые еще не загружены локально.';
} else if (hasSyncFailure) {
syncIssueSection.classList.remove('hidden');
syncIssueSummary.textContent = 'Последняя синхронизация прайслистов завершилась ошибкой.';
} else {
syncIssueSection.classList.add('hidden');
syncIssueSummary.textContent = '';
}
if (syncIssueSection.classList.contains('hidden')) {
syncIssueAttempt.classList.add('hidden');
syncIssueAttempt.textContent = '';
syncIssueError.classList.add('hidden');
syncIssueError.textContent = '';
} else {
if (data.last_pricelist_attempt_at) {
syncIssueAttempt.classList.remove('hidden');
syncIssueAttempt.textContent = 'Последняя попытка: ' + new Date(data.last_pricelist_attempt_at).toLocaleString('ru-RU');
} else {
syncIssueAttempt.classList.add('hidden');
syncIssueAttempt.textContent = '';
}
if (data.last_pricelist_sync_error) {
syncIssueError.classList.remove('hidden');
syncIssueError.textContent = data.last_pricelist_sync_error;
} else {
syncIssueError.classList.add('hidden');
syncIssueError.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);
// }
// }
// ==================== SHARED PRICE REFRESH UTILITIES ====================
async function fetchLatestEstimatePricelistId() {
try {
const resp = await fetch('/api/pricelists?active_only=true&source=estimate&per_page=1');
if (!resp.ok) return null;
const data = await resp.json();
const list = data.pricelists || data.items || data;
if (Array.isArray(list) && list.length > 0) return Number(list[0].id);
} catch(_) {}
return null;
}
function _fmtMoneyDiff(value) {
if (!Number.isFinite(Number(value))) return 'N/A';
return '$ ' + Math.round(Number(value)).toLocaleString('ru-RU');
}
function _fmtArrow(prev, next) {
const diff = next - prev;
if (Math.abs(diff) < 0.5) return '';
const pct = prev > 0 ? Math.round((diff / prev) * 100) : 0;
const sign = diff > 0 ? '+' : '';
const color = diff > 0 ? 'text-red-600' : 'text-green-600';
return ` <span class="${color} text-xs font-medium">(${sign}${pct}%)</span>`;
}
function _buildDiffRow(lot, qty, prev, next) {
const prevLine = prev * qty;
const nextLine = next * qty;
const delta = next - prev;
const arrowColor = delta > 0 ? 'text-red-600' : 'text-green-600';
return `<tr class="border-b border-gray-100 last:border-0">
<td class="py-1.5 pr-3 text-sm text-gray-700 font-mono">${lot}</td>
<td class="py-1.5 px-2 text-sm text-right text-gray-500">${qty}</td>
<td class="py-1.5 px-2 text-sm text-right whitespace-nowrap">
<span class="text-gray-400 line-through text-xs">${_fmtMoneyDiff(prev)}</span>
<span class="${arrowColor} font-medium ml-1">${_fmtMoneyDiff(next)}</span>
</td>
<td class="py-1.5 pl-2 text-sm text-right whitespace-nowrap">
<span class="text-gray-400 line-through text-xs">${_fmtMoneyDiff(prevLine)}</span>
<span class="${arrowColor} font-medium ml-1">${_fmtMoneyDiff(nextLine)}</span>
</td>
</tr>`;
}
function showPriceDiffModal(results) {
const body = document.getElementById('price-diff-modal-body');
if (!body) return;
const sections = results.filter(r => !r.skipped);
if (sections.length === 0) {
body.innerHTML = '<p class="text-gray-500 text-sm text-center py-4">Обновление цен отключено для всех конфигураций</p>';
document.getElementById('price-diff-modal').classList.remove('hidden');
return;
}
let html = '';
let anyChanges = false;
for (const r of sections) {
if (r.error) {
html += `<div class="text-sm text-red-600 bg-red-50 rounded px-3 py-2">${r.configName ? `<span class="font-medium">${r.configName}:</span> ` : ''}Ошибка обновления цен</div>`;
continue;
}
const diffs = (r.itemDiffs || []).filter(d => Math.abs(d.prevPrice - d.newPrice) > 0.01);
const totalDelta = (r.newTotal || 0) - (r.prevTotal || 0);
if (results.length > 1) {
html += `<div class="text-sm font-semibold text-gray-800 mb-1">${r.configName || '—'}</div>`;
}
if (diffs.length === 0) {
html += `<div class="text-sm text-gray-500 bg-gray-50 rounded px-3 py-2 mb-2">Изменений нет</div>`;
} else {
anyChanges = true;
html += `<div class="overflow-x-auto mb-2">
<table class="w-full text-left">
<thead>
<tr class="border-b border-gray-200 text-xs text-gray-500 uppercase">
<th class="pb-1 pr-3 font-medium">Компонент</th>
<th class="pb-1 px-2 text-right font-medium">Кол.</th>
<th class="pb-1 px-2 text-right font-medium">Цена / шт.</th>
<th class="pb-1 pl-2 text-right font-medium">Сумма</th>
</tr>
</thead>
<tbody>${diffs.map(d => _buildDiffRow(d.lot_name, d.quantity, d.prevPrice, d.newPrice)).join('')}</tbody>
</table>
</div>`;
}
const totalColor = totalDelta > 0 ? 'text-red-600' : totalDelta < 0 ? 'text-green-600' : 'text-gray-600';
const totalArrow = _fmtArrow(r.prevTotal || 0, r.newTotal || 0);
html += `<div class="flex justify-between items-center text-sm bg-gray-50 rounded px-3 py-2 mb-1">
<span class="text-gray-600 font-medium">Итог конфигурации</span>
<span>
<span class="text-gray-400 line-through text-xs mr-1">${_fmtMoneyDiff(r.prevTotal || 0)}</span>
<span class="${totalColor} font-semibold">${_fmtMoneyDiff(r.newTotal || 0)}</span>${totalArrow}
</span>
</div>`;
}
if (!anyChanges && sections.every(r => !r.error)) {
html = '<div class="text-sm text-gray-500 text-center py-6">Цены актуальны — изменений нет</div>' + html;
}
body.innerHTML = html;
document.getElementById('price-diff-modal').classList.remove('hidden');
}
function closePriceDiffModal() {
document.getElementById('price-diff-modal')?.classList.add('hidden');
}
// Call functions immediately to ensure they run even before DOMContentLoaded
// This ensures username and admin link are visible ASAP
loadDBUser();
checkWritePermission();
// Dead-man's switch: detect if the backend process has stopped
(function() {
const POLL_MS = 5000;
const FAIL_THRESHOLD = 2;
let failCount = 0;
async function checkBackend() {
try {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 3000);
const resp = await fetch('/health', { signal: ctrl.signal });
clearTimeout(timer);
if (resp.ok) {
failCount = 0;
document.getElementById('backend-offline-overlay').classList.add('hidden');
} else {
failCount++;
}
} catch (_) {
failCount++;
}
if (failCount >= FAIL_THRESHOLD) {
document.getElementById('backend-offline-overlay').classList.remove('hidden');
}
}
setInterval(checkBackend, POLL_MS);
})();
// Load last sync time - removed since dropdown is gone
// document.addEventListener('DOMContentLoaded', loadLastSyncTime);
</script>
</body>
</html>
{{end}}