Files
QuoteForge/web/templates/config_revisions.html
T
Mikhail Chusavitin 4900cd073c feat: server-driven configurator settings via qt_settings
Replaces hardcoded JS category filters and config-type buttons with
server-pushed settings synced from qt_settings (MariaDB) → local_qt_settings (SQLite).

- new table local_qt_settings (AutoMigrate) — synced after component sync
- GET /api/configurator-settings returns config_types, tab_config,
  always_visible_tabs, required_categories with hardcoded fallbacks
- new-config modal: type buttons rendered from server data (Сервер/СХД static fallback)
- configurator: TAB_CONFIG and category filter driven by server; required-category badge on tabs
- SyncQtSettings wired into SyncComponents and SyncAll handlers (non-fatal on old server)
- bible-local/server-contract-qt-settings.md — contract for server-side agent
- page titles: OFS → QFS

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 09:33:31 +03:00

237 lines
12 KiB
HTML

{{define "title"}}QFS Ревизии{{end}}
{{define "content"}}
<div class="space-y-4">
<div class="flex items-center gap-3">
<a href="/projects" class="text-gray-500 hover:text-gray-700" title="Все проекты">
<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="M3 9.75L12 3l9 6.75v9A2.25 2.25 0 0118.75 21h-13.5A2.25 2.25 0 013 18.75v-9z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 21v-6h6v6"></path>
</svg>
</a>
<div class="text-2xl font-bold flex items-center gap-2" id="revisions-breadcrumbs">
<a id="breadcrumb-code-link" href="/projects" class="text-blue-700 hover:underline">
<span id="breadcrumb-code"></span>
</a>
<span class="text-gray-400">-</span>
<a id="breadcrumb-variant-link" href="/projects" class="text-blue-700 hover:underline">
<span id="breadcrumb-variant">main</span>
</a>
<span class="text-gray-400">-</span>
<a id="breadcrumb-config-link" href="/configurator" class="text-blue-700 hover:underline">
<span id="breadcrumb-config"></span>
</a>
<span class="text-gray-400">-</span>
<span class="text-gray-600">Ревизии</span>
</div>
</div>
<div id="revisions-list">
<div class="text-center py-8 text-gray-500">Загрузка...</div>
</div>
</div>
<script>
const configUUID = '{{.ConfigUUID}}';
let configData = null;
let projectData = null;
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text || '';
return div.innerHTML;
}
function formatMoney(value) {
const num = Number(value);
if (!Number.isFinite(num)) return '—';
return '$\u00A0' + num.toLocaleString('ru-RU', {minimumFractionDigits: 0, maximumFractionDigits: 2});
}
function parseVersionSnapshot(version) {
try {
const raw = typeof version.data === 'string' ? version.data : '';
if (!raw) return { article: '—', price: null, serverCount: 1 };
const parsed = JSON.parse(raw);
return {
article: parsed.article || '—',
price: typeof parsed.total_price === 'number' ? parsed.total_price : null,
serverCount: Number.isFinite(Number(parsed.server_count)) && Number(parsed.server_count) > 0
? Number(parsed.server_count)
: 1
};
} catch (_) {
return { article: '—', price: null, serverCount: 1 };
}
}
function truncateBreadcrumbSpecName(name) {
const maxLength = 16;
if (!name || name.length <= maxLength) return name;
return name.slice(0, maxLength - 1) + '…';
}
async function loadConfigInfo() {
try {
const resp = await fetch('/api/configs/' + configUUID);
if (!resp.ok) {
document.getElementById('revisions-list').innerHTML =
'<div class="bg-white rounded-lg shadow p-8 text-center text-red-600">Конфигурация не найдена</div>';
return false;
}
configData = await resp.json();
const fullConfigName = configData.name || 'Конфигурация';
const configBreadcrumbEl = document.getElementById('breadcrumb-config');
configBreadcrumbEl.textContent = truncateBreadcrumbSpecName(fullConfigName);
configBreadcrumbEl.title = fullConfigName;
document.getElementById('breadcrumb-config-link').href = '/configurator?uuid=' + configUUID;
if (configData.project_uuid) {
const projResp = await fetch('/api/projects/' + configData.project_uuid);
if (projResp.ok) {
projectData = await projResp.json();
document.getElementById('breadcrumb-code').textContent = projectData.code || '—';
const variant = (projectData.variant || '').trim();
document.getElementById('breadcrumb-variant').textContent = variant === '' ? 'main' : variant;
document.getElementById('breadcrumb-variant-link').href = '/projects/' + projectData.uuid;
// Find main variant for code link
const allResp = await fetch('/api/projects/all');
if (allResp.ok) {
const allProjects = await allResp.json();
const main = (Array.isArray(allProjects) ? allProjects : []).find(
p => p.code === projectData.code && (p.variant || '').trim() === ''
);
if (main) {
document.getElementById('breadcrumb-code-link').href = '/projects/' + main.uuid;
}
}
}
}
return true;
} catch (e) {
document.getElementById('revisions-list').innerHTML =
'<div class="bg-white rounded-lg shadow p-8 text-center text-red-600">Ошибка загрузки</div>';
return false;
}
}
async function loadVersions() {
try {
const resp = await fetch('/api/configs/' + configUUID + '/versions?limit=200');
if (!resp.ok) {
document.getElementById('revisions-list').innerHTML =
'<div class="bg-white rounded-lg shadow p-8 text-center text-red-600">Ошибка загрузки ревизий</div>';
return;
}
const data = await resp.json();
const versions = data.versions || [];
renderVersions(versions);
} catch (e) {
document.getElementById('revisions-list').innerHTML =
'<div class="bg-white rounded-lg shadow p-8 text-center text-red-600">Ошибка загрузки</div>';
}
}
function renderVersions(versions) {
const currentVersionNo = configData && configData.current_version_no ? Number(configData.current_version_no) : null;
const snapshots = versions.filter(v => Number(v.version_no) !== currentVersionNo);
if (snapshots.length === 0) {
document.getElementById('revisions-list').innerHTML =
'<div class="bg-white rounded-lg shadow p-8 text-center text-gray-500">Нет прошлых снимков. Рабочая версия остается main.</div>';
return;
}
let html = '<div class="bg-white rounded-lg shadow overflow-hidden"><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 += '<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">Автор</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">Цена</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-right text-xs font-medium text-gray-500 uppercase">Действия</th>';
html += '</tr></thead><tbody class="divide-y">';
snapshots.forEach((v) => {
const date = new Date(v.created_at).toLocaleString('ru-RU');
const author = v.created_by || '—';
const snapshot = parseVersionSnapshot(v);
html += '<tr class="hover:bg-gray-50">';
html += '<td class="px-4 py-3 text-sm font-medium">';
html += 'v' + v.version_no;
html += '</td>';
html += '<td class="px-4 py-3 text-sm text-gray-500">' + escapeHtml(date) + '</td>';
html += '<td class="px-4 py-3 text-sm text-gray-500">' + escapeHtml(author) + '</td>';
html += '<td class="px-4 py-3 text-sm text-gray-500">' + escapeHtml(snapshot.article) + '</td>';
html += '<td class="px-4 py-3 text-sm text-gray-500">' + formatMoney(snapshot.price) + '</td>';
html += '<td class="px-4 py-3 text-sm text-gray-500">' + escapeHtml(String(snapshot.serverCount)) + '</td>';
html += '<td class="px-4 py-3 text-sm text-right space-x-2">';
// Open in configurator (readonly view)
html += '<a href="/configurator?uuid=' + configUUID + '&version=' + v.version_no + '" class="text-blue-600 hover:text-blue-800 inline-block" title="Открыть">';
html += '<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="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path></svg></a>';
// Clone from this version
html += '<button onclick="cloneFromVersion(' + v.version_no + ')" class="text-green-600 hover:text-green-800" title="Скопировать из этой ревизии">';
html += '<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="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path></svg></button>';
html += '<button onclick="rollbackToVersion(' + v.version_no + ')" class="text-orange-600 hover:text-orange-800" title="Восстановить этот снимок как новую main-версию">';
html += '<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="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6"></path></svg></button>';
html += '</td></tr>';
});
html += '</tbody></table></div>';
document.getElementById('revisions-list').innerHTML = html;
}
async function cloneFromVersion(versionNo) {
const name = prompt('Название копии:', (configData ? configData.name : '') + ' (v' + versionNo + ')');
if (!name) return;
const resp = await fetch('/api/configs/' + configUUID + '/clone', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({name: name, from_version: versionNo})
});
if (!resp.ok) {
const data = await resp.json().catch(() => ({}));
alert(data.error || 'Не удалось скопировать');
return;
}
const created = await resp.json();
showToast('Копия создана', 'success');
if (created && created.uuid) {
window.location.href = '/configurator?uuid=' + created.uuid;
}
}
async function rollbackToVersion(versionNo) {
if (!confirm('Восстановить снимок v' + versionNo + ' как новую рабочую версию main?')) return;
const resp = await fetch('/api/configs/' + configUUID + '/rollback', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({target_version: versionNo})
});
if (!resp.ok) {
const data = await resp.json().catch(() => ({}));
alert(data.error || 'Не удалось восстановить');
return;
}
showToast('Ревизия восстановлена', 'success');
loadVersions();
}
document.addEventListener('DOMContentLoaded', async function() {
const ok = await loadConfigInfo();
if (!ok) return;
await loadVersions();
});
</script>
{{end}}
{{template "base" .}}