Add configuration revisions system and project variant deletion

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>
This commit is contained in:
2026-02-16 22:30:33 +03:00
parent 8508ee2921
commit 2e973b6d78
10 changed files with 407 additions and 17 deletions

View File

@@ -0,0 +1,202 @@
{{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" .}}