Features: - Configuration versioning: immutable snapshots in local_configuration_versions - Revisions UI: /configs/:uuid/revisions page to view version history - Clone from version: ability to clone configuration from specific revision - Project variant deletion: DELETE /api/projects/:uuid endpoint - Updated CLAUDE.md with new architecture details and endpoints Architecture updates: - local_configuration_versions table for immutable snapshots - Version tracking on each configuration save - Rollback capability to previous versions - Variant deletion with main variant protection Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
203 lines
9.8 KiB
HTML
203 lines
9.8 KiB
HTML
{{define "title"}}Ревизии - QuoteForge{{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;
|
||
}
|
||
|
||
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();
|
||
|
||
document.getElementById('breadcrumb-config').textContent = configData.name || 'Конфигурация';
|
||
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) {
|
||
if (versions.length === 0) {
|
||
document.getElementById('revisions-list').innerHTML =
|
||
'<div class="bg-white rounded-lg shadow p-8 text-center text-gray-500">Нет ревизий</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-right text-xs font-medium text-gray-500 uppercase">Действия</th>';
|
||
html += '</tr></thead><tbody class="divide-y">';
|
||
|
||
versions.forEach((v, idx) => {
|
||
const date = new Date(v.created_at).toLocaleString('ru-RU');
|
||
const author = v.created_by || '—';
|
||
const note = v.change_note || '—';
|
||
const isCurrent = idx === 0;
|
||
|
||
html += '<tr class="hover:bg-gray-50' + (isCurrent ? ' bg-blue-50' : '') + '">';
|
||
html += '<td class="px-4 py-3 text-sm font-medium">';
|
||
html += 'v' + v.version_no;
|
||
if (isCurrent) html += ' <span class="text-xs text-blue-600 font-normal">(текущая)</span>';
|
||
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(note) + '</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>';
|
||
|
||
// Rollback (not for current version)
|
||
if (!isCurrent) {
|
||
html += '<button onclick="rollbackToVersion(' + v.version_no + ')" class="text-orange-600 hover:text-orange-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="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 + '?')) 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" .}}
|