Add projects table controls and sync status tab with app version

This commit is contained in:
Mikhail Chusavitin
2026-02-06 14:02:21 +03:00
parent f665e9b08c
commit 651427e0dd
8 changed files with 532 additions and 34 deletions

View File

@@ -10,6 +10,7 @@
<button onclick="loadTab('alerts')" id="btn-alerts" class="text-blue-600 font-medium">Алерты</button>
<button onclick="loadTab('components')" id="btn-components" class="text-gray-600">Компоненты</button>
<button onclick="loadTab('pricelists')" id="btn-pricelists" class="text-gray-600">Прайслисты</button>
<button onclick="loadTab('sync-status')" id="btn-sync-status" class="text-gray-600 hidden">Статус синхронизации</button>
<button onclick="loadTab('all-configs')" id="btn-all-configs" class="text-gray-600 hidden">Все конфигурации</button>
</div>
<button onclick="recalculateAll()" id="btn-recalc" class="px-3 py-1 bg-green-600 text-white text-sm rounded hover:bg-green-700">
@@ -85,6 +86,30 @@
<div id="pricelists-pagination" class="flex justify-center space-x-2 mt-4"></div>
</div>
<!-- Sync Status Tab Content (hidden by default) -->
<div id="sync-status-tab-content" class="hidden">
<div class="mb-4">
<h2 class="text-xl font-semibold">Статус синхронизации</h2>
</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>
</tr>
</thead>
<tbody id="sync-users-status-body" class="bg-white divide-y divide-gray-200">
<tr>
<td colspan="3" class="px-6 py-4 text-sm text-gray-500">Загрузка...</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Create Modal -->
<div id="pricelists-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">
@@ -226,16 +251,21 @@ let pricelistsPage = 1;
let pricelistsCanWrite = false;
let isCreatingPricelist = false;
let cachedDbUsername = null;
let syncUsersStatusTimer = null;
async function loadTab(tab) {
currentTab = tab;
currentPage = 1;
currentSearch = '';
document.getElementById('search-input').value = '';
if (tab !== 'sync-status') {
stopSyncUsersStatusRefresh();
}
document.getElementById('btn-alerts').className = tab === 'alerts' ? 'text-blue-600 font-medium' : 'text-gray-600';
document.getElementById('btn-components').className = tab === 'components' ? 'text-blue-600 font-medium' : 'text-gray-600';
document.getElementById('btn-pricelists').className = tab === 'pricelists' ? 'text-blue-600 font-medium' : 'text-gray-600';
document.getElementById('btn-sync-status').className = (tab === 'sync-status' ? 'text-blue-600 font-medium' : 'text-gray-600') + (pricelistsCanWrite ? '' : ' hidden');
document.getElementById('btn-all-configs').className = tab === 'all-configs' ? 'text-blue-600 font-medium' : 'text-gray-600 hidden';
// Show/hide elements based on tab
@@ -244,35 +274,69 @@ async function loadTab(tab) {
document.getElementById('pagination').className = 'flex justify-between items-center mt-4 pt-4 border-t';
document.getElementById('btn-all-configs').className = 'text-gray-600 hidden'; // Hide this tab for components
document.getElementById('pricelists-tab-content').className = 'hidden';
document.getElementById('sync-status-tab-content').className = 'hidden';
document.getElementById('tab-content').className = '';
} else if (tab === 'all-configs') {
document.getElementById('search-bar').className = 'mb-4 hidden'; // Hide search for all configs
document.getElementById('pagination').className = 'flex justify-between items-center mt-4 pt-4 border-t'; // Show pagination
document.getElementById('btn-all-configs').className = 'text-blue-600 font-medium'; // Show this tab for all configs
document.getElementById('pricelists-tab-content').className = 'hidden';
document.getElementById('sync-status-tab-content').className = 'hidden';
document.getElementById('tab-content').className = '';
} else if (tab === 'pricelists') {
document.getElementById('search-bar').className = 'mb-4 hidden';
document.getElementById('pagination').className = 'hidden';
document.getElementById('btn-all-configs').className = 'text-gray-600 hidden';
document.getElementById('pricelists-tab-content').className = '';
document.getElementById('sync-status-tab-content').className = 'hidden';
document.getElementById('tab-content').className = 'hidden';
// Load pricelists when pricelists tab is selected
checkPricelistWritePermission();
loadPricelists(1);
} else if (tab === 'sync-status') {
document.getElementById('search-bar').className = 'mb-4 hidden';
document.getElementById('pagination').className = 'hidden';
document.getElementById('btn-all-configs').className = 'text-gray-600 hidden';
document.getElementById('pricelists-tab-content').className = 'hidden';
document.getElementById('sync-status-tab-content').className = '';
document.getElementById('tab-content').className = 'hidden';
await checkPricelistWritePermission();
if (!pricelistsCanWrite) {
await loadTab('alerts');
return;
}
await loadUsersSyncStatus();
startSyncUsersStatusRefresh();
} else {
document.getElementById('search-bar').className = 'mb-4 hidden';
document.getElementById('pagination').className = 'hidden';
document.getElementById('btn-all-configs').className = 'text-gray-600 hidden';
document.getElementById('pricelists-tab-content').className = 'hidden';
document.getElementById('sync-status-tab-content').className = 'hidden';
document.getElementById('tab-content').className = '';
}
if (tab !== 'pricelists') {
if (tab !== 'pricelists' && tab !== 'sync-status') {
await loadData();
}
}
function stopSyncUsersStatusRefresh() {
if (syncUsersStatusTimer) {
clearInterval(syncUsersStatusTimer);
syncUsersStatusTimer = null;
}
}
function startSyncUsersStatusRefresh() {
stopSyncUsersStatusRefresh();
syncUsersStatusTimer = setInterval(() => {
if (currentTab === 'sync-status' && pricelistsCanWrite) {
loadUsersSyncStatus();
}
}, 30000);
}
async function loadData() {
document.getElementById('tab-content').innerHTML = '<div class="text-center py-8 text-gray-500">Загрузка...</div>';
@@ -902,11 +966,12 @@ function renderAllConfigs(configs) {
document.getElementById('tab-content').innerHTML = html;
}
document.addEventListener('DOMContentLoaded', () => {
document.addEventListener('DOMContentLoaded', async () => {
await checkPricelistWritePermission();
// Check URL params for initial tab
const urlParams = new URLSearchParams(window.location.search);
const initialTab = urlParams.get('tab') || 'alerts';
loadTab(initialTab);
await loadTab(initialTab);
// Add event listeners for preview updates
document.getElementById('modal-period').addEventListener('change', fetchPreview);
@@ -930,9 +995,89 @@ async function checkPricelistWritePermission() {
Создать прайслист
</button>
`;
document.getElementById('btn-sync-status').classList.remove('hidden');
if (currentTab === 'sync-status') {
await loadUsersSyncStatus();
startSyncUsersStatusRefresh();
}
} else {
document.getElementById('pricelists-create-btn-container').innerHTML = '';
document.getElementById('btn-sync-status').classList.add('hidden');
stopSyncUsersStatusRefresh();
if (currentTab === 'sync-status') {
await loadTab('alerts');
}
}
} catch (e) {
console.error('Failed to check pricelist write permission:', e);
document.getElementById('btn-sync-status').classList.add('hidden');
stopSyncUsersStatusRefresh();
}
}
function formatRelativeTime(lastSyncAt) {
const timestamp = new Date(lastSyncAt);
if (Number.isNaN(timestamp.getTime())) return '—';
const diffMinutes = Math.max(1, Math.floor((Date.now() - timestamp.getTime()) / 60000));
if (diffMinutes < 60) return `${diffMinutes} мин назад`;
const diffHours = Math.floor(diffMinutes / 60);
if (diffHours < 24) return `${diffHours} ч назад`;
const diffDays = Math.floor(diffHours / 24);
if (diffDays < 7) return `${diffDays} дн назад`;
const diffWeeks = Math.floor(diffDays / 7);
if (diffWeeks < 5) return `${diffWeeks} нед назад`;
const diffMonths = Math.floor(diffDays / 30);
if (diffMonths < 12) return `${diffMonths} мес назад`;
const diffYears = Math.floor(diffDays / 365);
return `${diffYears} г назад`;
}
async function loadUsersSyncStatus() {
if (!pricelistsCanWrite) return;
const body = document.getElementById('sync-users-status-body');
if (!body) return;
try {
const resp = await fetch('/api/sync/users-status');
const data = await resp.json();
if (!resp.ok) {
throw new Error(data.error || 'Ошибка загрузки');
}
const users = data.users || [];
if (users.length === 0) {
body.innerHTML = `
<tr>
<td colspan="3" class="px-6 py-4 text-sm text-gray-500">
Нет данных о синхронизации пользователей
</td>
</tr>
`;
return;
}
body.innerHTML = users.map(u => {
const statusClass = u.is_online ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-700';
const statusText = u.is_online ? 'онлайн' : formatRelativeTime(u.last_sync_at);
return `
<tr>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-800">${escapeHtml(u.username || '—')}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-600">${escapeHtml(u.app_version || '—')}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm">
<span class="px-2 py-1 text-xs rounded-full ${statusClass}">${statusText}</span>
</td>
</tr>
`;
}).join('');
} catch (e) {
body.innerHTML = `
<tr>
<td colspan="3" class="px-6 py-4 text-sm text-red-600">
Ошибка загрузки статусов синхронизации: ${escapeHtml(e.message || String(e))}
</td>
</tr>
`;
}
}

View File

@@ -30,6 +30,11 @@
<script>
let status = 'active';
let projectsSearch = '';
let authorSearch = '';
let currentPage = 1;
let perPage = 10;
let sortField = 'created_at';
let sortDir = 'desc';
function escapeHtml(text) {
const div = document.createElement('div');
@@ -41,8 +46,33 @@ function formatMoney(v) {
return '$' + (v || 0).toLocaleString('en-US', {minimumFractionDigits: 2});
}
function formatDateTime(value) {
if (!value) return '—';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '—';
return date.toLocaleString('ru-RU', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
}
function toggleSort(field) {
if (sortField === field) {
sortDir = sortDir === 'asc' ? 'desc' : 'asc';
} else {
sortField = field;
sortDir = field === 'name' ? 'asc' : 'desc';
}
currentPage = 1;
loadProjects();
}
function setStatus(value) {
status = value;
currentPage = 1;
document.getElementById('status-active-btn').className = value === 'active'
? 'px-4 py-2 text-sm font-medium bg-blue-600 text-white'
: 'px-4 py-2 text-sm font-medium bg-white text-gray-700 hover:bg-gray-50';
@@ -57,36 +87,73 @@ async function loadProjects() {
root.innerHTML = '<div class="text-gray-500">Загрузка...</div>';
let rows = [];
let total = 0;
let totalPages = 0;
let page = currentPage;
try {
const resp = await fetch('/api/projects?status=' + status + '&search=' + encodeURIComponent(projectsSearch));
const params = new URLSearchParams({
status: status,
search: projectsSearch,
author: authorSearch,
page: String(currentPage),
per_page: String(perPage),
sort: sortField,
dir: sortDir
});
const resp = await fetch('/api/projects?' + params.toString());
if (!resp.ok) {
throw new Error('HTTP ' + resp.status);
}
const data = await resp.json();
rows = data.projects || [];
total = data.total || 0;
totalPages = data.total_pages || 0;
page = data.page || currentPage;
currentPage = page;
} catch (e) {
root.innerHTML = '<div class="text-red-600">Ошибка загрузки проектов: ' + escapeHtml(String(e.message || e)) + '</div>';
return;
}
if (!rows.length) {
root.innerHTML = '<div class="text-gray-500">Проектов нет</div>';
return;
}
let html = '<div class="overflow-x-auto"><table class="w-full">';
html += '<thead class="bg-gray-50"><tr>';
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Название проекта</th>';
html += '<thead class="bg-gray-50">';
html += '<tr>';
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">';
html += '<button type="button" onclick="toggleSort(\'name\')" class="inline-flex items-center gap-1 hover:text-gray-700">Название проекта';
if (sortField === 'name') {
html += sortDir === 'asc' ? ' <span>↑</span>' : ' <span>↓</span>';
}
html += '</button></th>';
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Автор</th>';
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">';
html += '<button type="button" onclick="toggleSort(\'created_at\')" class="inline-flex items-center gap-1 hover:text-gray-700">Создан';
if (sortField === 'created_at') {
html += sortDir === 'asc' ? ' <span>↑</span>' : ' <span>↓</span>';
}
html += '</button></th>';
html += '<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Кол-во квот</th>';
html += '<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Сумма</th>';
html += '<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Действия</th>';
html += '</tr></thead><tbody class="divide-y">';
html += '</tr>';
html += '<tr>';
html += '<th class="px-4 py-2"></th>';
html += '<th class="px-4 py-2"><input id="projects-author-filter" type="text" value="' + escapeHtml(authorSearch) + '" placeholder="Фильтр автора" class="w-full px-2 py-1 border rounded text-xs focus:ring-1 focus:ring-blue-500 focus:border-blue-500"></th>';
html += '<th class="px-4 py-2"></th>';
html += '<th class="px-4 py-2"></th>';
html += '<th class="px-4 py-2"></th>';
html += '<th class="px-4 py-2"></th>';
html += '</tr>';
html += '</thead><tbody class="divide-y">';
if (!rows.length) {
html += '<tr><td colspan="6" class="px-4 py-6 text-sm text-gray-500 text-center">Проектов нет</td></tr>';
}
rows.forEach(p => {
html += '<tr class="hover:bg-gray-50">';
html += '<td class="px-4 py-3 text-sm font-medium"><a class="text-blue-600 hover:underline" href="/projects/' + p.uuid + '">' + escapeHtml(p.name) + '</a></td>';
html += '<td class="px-4 py-3 text-sm text-gray-600">' + escapeHtml(p.owner_username || '—') + '</td>';
html += '<td class="px-4 py-3 text-sm text-gray-600">' + escapeHtml(formatDateTime(p.created_at)) + '</td>';
html += '<td class="px-4 py-3 text-sm text-right text-gray-700">' + (p.config_count || 0) + '</td>';
html += '<td class="px-4 py-3 text-sm text-right text-gray-700">' + formatMoney(p.total) + '</td>';
html += '<td class="px-4 py-3 text-sm text-right"><div class="inline-flex items-center gap-2">';
@@ -117,7 +184,38 @@ async function loadProjects() {
});
html += '</tbody></table></div>';
if (totalPages > 1) {
html += '<div class="flex items-center justify-between mt-4 pt-4 border-t">';
html += '<div class="text-sm text-gray-600">Показано ' + rows.length + ' из ' + total + '</div>';
html += '<div class="inline-flex items-center gap-1">';
html += '<button type="button" onclick="goToPage(' + (page - 1) + ')" ' + (page <= 1 ? 'disabled' : '') + ' class="px-3 py-1 text-sm border rounded ' + (page <= 1 ? 'text-gray-300 border-gray-200 cursor-not-allowed' : 'text-gray-700 hover:bg-gray-50') + '">&larr;</button>';
const startPage = Math.max(1, page - 2);
const endPage = Math.min(totalPages, page + 2);
for (let i = startPage; i <= endPage; i++) {
html += '<button type="button" onclick="goToPage(' + i + ')" class="px-3 py-1 text-sm border rounded ' + (i === page ? 'bg-blue-600 text-white border-blue-600' : 'text-gray-700 border-gray-300 hover:bg-gray-50') + '">' + i + '</button>';
}
html += '<button type="button" onclick="goToPage(' + (page + 1) + ')" ' + (page >= totalPages ? 'disabled' : '') + ' class="px-3 py-1 text-sm border rounded ' + (page >= totalPages ? 'text-gray-300 border-gray-200 cursor-not-allowed' : 'text-gray-700 hover:bg-gray-50') + '">&rarr;</button>';
html += '</div>';
html += '</div>';
}
root.innerHTML = html;
const authorInput = document.getElementById('projects-author-filter');
if (authorInput) {
authorInput.addEventListener('input', function(e) {
authorSearch = (e.target.value || '').trim();
currentPage = 1;
loadProjects();
});
}
}
function goToPage(page) {
if (page < 1) return;
currentPage = page;
loadProjects();
}
async function createProject() {
@@ -223,6 +321,7 @@ loadProjects();
document.getElementById('projects-search').addEventListener('input', function(e) {
projectsSearch = (e.target.value || '').trim();
currentPage = 1;
loadProjects();
});
</script>