feat: implement sync icon + pricelist badge UI improvements

- Replace text 'Online/Offline' with SVG icons in sync status
- Change sync button to circular arrow icon
- Add dropdown menu with push changes, full sync, and last sync status
- Add pricelist version badge to configuration page
- Load pricelist version via /api/pricelists/latest on DOMContentLoaded

This completes task 1 of Phase 2.5 (UI Improvements) as specified in CLAUDE.md
This commit is contained in:
Mikhail Chusavitin
2026-02-02 11:18:24 +03:00
parent 9bd2acd4f7
commit e206531364
3 changed files with 159 additions and 28 deletions

View File

@@ -60,6 +60,75 @@
setTimeout(() => el.innerHTML = '', 3000); setTimeout(() => el.innerHTML = '', 3000);
} }
// Dropdown functionality
document.addEventListener('DOMContentLoaded', function() {
const dropdownButton = document.getElementById('sync-dropdown-button');
const dropdownMenu = document.getElementById('sync-dropdown-menu');
if (dropdownButton && dropdownMenu) {
dropdownButton.addEventListener('click', function(e) {
e.stopPropagation();
dropdownMenu.classList.toggle('hidden');
});
// Close dropdown when clicking outside
document.addEventListener('click', function(e) {
if (!dropdownButton.contains(e.target) && !dropdownMenu.contains(e.target)) {
dropdownMenu.classList.add('hidden');
}
});
}
checkDbStatus();
checkWritePermission();
});
function pushPendingChanges() {
fetch('/api/sync/push', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
showToast('Синхронизировано: ' + data.synced + ' изменений', 'success');
} else {
showToast('Ошибка: ' + (data.error || 'неизвестная ошибка'), 'error');
}
htmx.trigger('#sync-status', 'refresh');
document.getElementById('sync-dropdown-menu').classList.add('hidden');
})
.catch(error => {
showToast('Ошибка синхронизации: ' + error.message, 'error');
document.getElementById('sync-dropdown-menu').classList.add('hidden');
});
}
function fullSync() {
fetch('/api/sync/all', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
showToast('Полная синхронизация завершена', 'success');
} else {
showToast('Ошибка: ' + (data.error || 'неизвестная ошибка'), 'error');
}
htmx.trigger('#sync-status', 'refresh');
document.getElementById('sync-dropdown-menu').classList.add('hidden');
})
.catch(error => {
showToast('Ошибка полной синхронизации: ' + error.message, 'error');
document.getElementById('sync-dropdown-menu').classList.add('hidden');
});
}
async function checkDbStatus() { async function checkDbStatus() {
try { try {
const resp = await fetch('/api/db-status'); const resp = await fetch('/api/db-status');
@@ -96,10 +165,24 @@
} }
} }
document.addEventListener('DOMContentLoaded', function() { // Load last sync time for dropdown
checkDbStatus(); async function loadLastSyncTime() {
checkWritePermission(); 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);
}
}
// Load last sync time when page loads
document.addEventListener('DOMContentLoaded', loadLastSyncTime);
</script> </script>
</body> </body>
</html> </html>

View File

@@ -10,6 +10,15 @@
</button> </button>
</div> </div>
<div id="pricelist-badge" class="mt-4 text-sm text-gray-600 hidden">
<span class="bg-blue-100 text-blue-800 px-2 py-1 rounded-full">
<svg class="w-4 h-4 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
</svg>
Активный прайслист: <span id="pricelist-version">-</span>
</span>
</div>
<div id="configs-list"> <div id="configs-list">
<div class="text-center py-8 text-gray-500">Загрузка...</div> <div class="text-center py-8 text-gray-500">Загрузка...</div>
</div> </div>
@@ -398,7 +407,25 @@ async function loadConfigs() {
} }
} }
document.addEventListener('DOMContentLoaded', loadConfigs); document.addEventListener('DOMContentLoaded', function() {
loadConfigs();
// Load latest pricelist version for badge
loadLatestPricelistVersion();
});
async function loadLatestPricelistVersion() {
try {
const resp = await fetch('/api/pricelists/latest');
if (resp.ok) {
const pricelist = await resp.json();
document.getElementById('pricelist-version').textContent = pricelist.version;
document.getElementById('pricelist-badge').classList.remove('hidden');
}
} catch(e) {
console.error('Failed to load pricelist version:', e);
}
}
</script> </script>
{{end}} {{end}}

View File

@@ -1,37 +1,58 @@
{{define "sync_status"}} {{define "sync_status"}}
<div class="flex items-center gap-2"> <div class="flex items-center gap-2 relative">
{{if .IsOffline}} {{if .IsOffline}}
<span class="flex items-center gap-1 text-red-600" title="Offline"> <span class="flex items-center gap-1 text-red-600" title="Offline">
<span class="w-2 h-2 bg-red-500 rounded-full"></span> <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<span class="text-xs">Offline</span> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
</svg>
</span> </span>
{{else}} {{else}}
<span class="flex items-center gap-1 text-green-600" title="Online"> <span class="flex items-center gap-1 text-green-600" title="Online">
<span class="w-2 h-2 bg-green-500 rounded-full"></span> <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<span class="text-xs">Online</span> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
</span> </span>
{{end}} {{end}}
{{if gt .PendingCount 0}} {{if gt .PendingCount 0}}
<span class="bg-yellow-100 text-yellow-800 px-2 py-0.5 rounded-full text-xs font-medium"> <span class="bg-yellow-100 text-yellow-800 px-2 py-0.5 rounded-full text-xs font-medium flex items-center gap-1">
{{.PendingCount}} pending <svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
</svg>
{{.PendingCount}}
</span> </span>
<button hx-post="/api/sync/push"
hx-swap="none"
hx-on::after-request="
if(event.detail.successful) {
const resp = JSON.parse(event.detail.xhr.response);
if(resp.success) {
showToast('Синхронизировано: ' + resp.synced + ' изменений', 'success');
} else {
showToast('Ошибка: ' + (resp.error || 'неизвестная ошибка'), 'error');
}
htmx.trigger('#sync-status', 'refresh');
}
"
class="text-blue-600 hover:text-blue-800 text-xs underline cursor-pointer">
Sync
</button>
{{end}} {{end}}
<!-- Dropdown button for sync actions -->
<div class="relative">
<button id="sync-dropdown-button" class="text-gray-600 hover:text-gray-800 text-xs flex items-center gap-1">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
</svg>
</button>
<!-- Dropdown menu -->
<div id="sync-dropdown-menu" class="absolute right-0 mt-2 w-48 bg-white rounded-md shadow-lg py-1 hidden z-10">
<button onclick="pushPendingChanges()" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 w-full text-left">
<svg class="w-4 h-4 inline mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
</svg>
Push changes
</button>
<button onclick="fullSync()" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 w-full text-left">
<svg class="w-4 h-4 inline mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
</svg>
Full sync
</button>
<div class="border-t border-gray-100 my-1"></div>
<div class="px-4 py-2 text-xs text-gray-500">
<svg class="w-4 h-4 inline mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
Последняя синхронизация: <span id="last-sync-time">-</span>
</div>
</div>
</div>
</div> </div>
{{end}} {{end}}