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:
@@ -30,7 +30,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="action-buttons" class="mt-4 grid grid-cols-1 sm:grid-cols-4 gap-3">
|
||||
<div id="action-buttons" class="mt-4 grid grid-cols-1 sm:grid-cols-5 gap-3">
|
||||
<button onclick="openNewVariantModal()" class="py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 font-medium">
|
||||
+ Новый вариант
|
||||
</button>
|
||||
@@ -43,6 +43,9 @@
|
||||
<button onclick="openProjectSettingsModal()" class="py-2 bg-gray-700 text-white rounded-lg hover:bg-gray-800 font-medium">
|
||||
Параметры
|
||||
</button>
|
||||
<button id="delete-variant-btn" onclick="deleteVariant()" class="py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 font-medium hidden">
|
||||
Удалить вариант
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<a id="tracker-link" href="https://tracker.yandex.ru/OPS-1933" target="_blank" rel="noopener noreferrer" class="text-sm text-blue-600 hover:text-blue-800 hover:underline">
|
||||
@@ -196,6 +199,24 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="transfer-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
|
||||
<div class="bg-white rounded-lg shadow-xl w-full max-w-md mx-4 p-6">
|
||||
<h2 class="text-xl font-semibold mb-4">Перенести квоту в другой вариант</h2>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Целевой вариант</label>
|
||||
<select id="transfer-variant-select" class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
</select>
|
||||
<input type="hidden" id="transfer-config-uuid">
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end space-x-3 mt-6">
|
||||
<button onclick="closeTransferModal()" class="px-4 py-2 text-gray-600 hover:text-gray-800">Отмена</button>
|
||||
<button onclick="transferConfig()" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">Перенести</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const projectUUID = '{{.ProjectUUID}}';
|
||||
let configStatusMode = 'active';
|
||||
@@ -241,7 +262,7 @@ async function loadVariantsForCode(code) {
|
||||
const data = await resp.json();
|
||||
const allProjects = Array.isArray(data) ? data : (data.projects || []);
|
||||
projectVariants = allProjects
|
||||
.filter(p => (p.code || '').trim() === code)
|
||||
.filter(p => (p.code || '').trim() === code && p.is_active !== false)
|
||||
.map(p => ({uuid: p.uuid, variant: (p.variant || '').trim()}));
|
||||
projectVariants.sort((a, b) => normalizeVariantLabel(a.variant).localeCompare(normalizeVariantLabel(b.variant)));
|
||||
} catch (e) {
|
||||
@@ -341,6 +362,7 @@ function renderConfigs(configs) {
|
||||
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Цена (за 1 шт)</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 += '<th class="px-4 py-3 text-center 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">';
|
||||
|
||||
@@ -363,11 +385,17 @@ function renderConfigs(configs) {
|
||||
html += '<td class="px-4 py-3 text-sm text-gray-500">$' + unitPrice.toLocaleString('en-US', {minimumFractionDigits: 2}) + '</td>';
|
||||
html += '<td class="px-4 py-3 text-sm text-gray-500">' + serverCount + '</td>';
|
||||
html += '<td class="px-4 py-3 text-sm text-right">$' + total.toLocaleString('en-US', {minimumFractionDigits: 2}) + '</td>';
|
||||
const versionNo = c.current_version_no || 1;
|
||||
html += '<td class="px-4 py-3 text-sm text-center text-gray-500">v' + versionNo + '</td>';
|
||||
html += '<td class="px-4 py-3 text-sm text-right space-x-2">';
|
||||
if (configStatusMode === 'archived') {
|
||||
html += '<button onclick="reactivateConfig(\'' + c.uuid + '\')" class="text-emerald-600 hover:text-emerald-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="M5 13l4 4L19 7"></path></svg></button>';
|
||||
} else {
|
||||
html += '<a href="/configs/' + c.uuid + '/revisions" class="text-purple-600 hover:text-purple-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="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></a>';
|
||||
html += '<button onclick="openTransferModal(\'' + c.uuid + '\')" class="text-indigo-600 hover:text-indigo-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 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4"></path></svg></button>';
|
||||
html += '<button onclick="openCloneModal(\'' + c.uuid + '\', \'' + escapeHtml(c.name).replace(/'/g, "\\'") + '\')" 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="openRenameModal(\'' + c.uuid + '\', \'' + escapeHtml(c.name).replace(/'/g, "\\'") + '\')" class="text-blue-600 hover:text-blue-800" title="Переименовать">';
|
||||
@@ -385,6 +413,7 @@ function renderConfigs(configs) {
|
||||
html += '<td class="px-4 py-3 text-sm text-gray-700">' + configs.length + '</td>';
|
||||
html += '<td class="px-4 py-3 text-sm text-right font-semibold text-gray-900">$' + totalSum.toLocaleString('en-US', {minimumFractionDigits: 2}) + '</td>';
|
||||
html += '<td class="px-4 py-3"></td>';
|
||||
html += '<td class="px-4 py-3"></td>';
|
||||
html += '</tr>';
|
||||
html += '</tfoot>';
|
||||
html += '</table></div>';
|
||||
@@ -757,12 +786,83 @@ function wildcardMatch(value, pattern) {
|
||||
return regex.test(value);
|
||||
}
|
||||
|
||||
async function deleteVariant() {
|
||||
if (!project) return;
|
||||
const variantLabel = normalizeVariantLabel(project.variant);
|
||||
if (!confirm('Удалить вариант «' + variantLabel + '»? Все квоты будут архивированы.')) return;
|
||||
const resp = await fetch('/api/projects/' + projectUUID, {method: 'DELETE'});
|
||||
if (!resp.ok) {
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
alert(data.error || 'Не удалось удалить вариант');
|
||||
return;
|
||||
}
|
||||
// Redirect to main variant or projects list
|
||||
const mainVariant = projectVariants.find(v => normalizeVariantLabel(v.variant) === 'main');
|
||||
if (mainVariant && mainVariant.uuid !== projectUUID) {
|
||||
window.location.href = '/projects/' + mainVariant.uuid;
|
||||
} else {
|
||||
window.location.href = '/projects';
|
||||
}
|
||||
}
|
||||
|
||||
function updateDeleteVariantButton() {
|
||||
const btn = document.getElementById('delete-variant-btn');
|
||||
if (!btn || !project) return;
|
||||
if ((project.variant || '').trim() !== '') {
|
||||
btn.classList.remove('hidden');
|
||||
} else {
|
||||
btn.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
function openTransferModal(configUUID) {
|
||||
const select = document.getElementById('transfer-variant-select');
|
||||
select.innerHTML = '';
|
||||
const otherVariants = projectVariants.filter(v => v.uuid !== projectUUID);
|
||||
if (otherVariants.length === 0) {
|
||||
alert('Нет других вариантов для переноса');
|
||||
return;
|
||||
}
|
||||
otherVariants.forEach(v => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = v.uuid;
|
||||
opt.textContent = normalizeVariantLabel(v.variant);
|
||||
select.appendChild(opt);
|
||||
});
|
||||
document.getElementById('transfer-config-uuid').value = configUUID;
|
||||
document.getElementById('transfer-modal').classList.remove('hidden');
|
||||
document.getElementById('transfer-modal').classList.add('flex');
|
||||
}
|
||||
|
||||
function closeTransferModal() {
|
||||
document.getElementById('transfer-modal').classList.add('hidden');
|
||||
document.getElementById('transfer-modal').classList.remove('flex');
|
||||
}
|
||||
|
||||
async function transferConfig() {
|
||||
const configUUID = document.getElementById('transfer-config-uuid').value;
|
||||
const targetProjectUUID = document.getElementById('transfer-variant-select').value;
|
||||
if (!configUUID || !targetProjectUUID) return;
|
||||
const resp = await fetch('/api/configs/' + configUUID + '/project', {
|
||||
method: 'PATCH',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({project_uuid: targetProjectUUID})
|
||||
});
|
||||
if (!resp.ok) {
|
||||
alert('Не удалось перенести квоту');
|
||||
return;
|
||||
}
|
||||
closeTransferModal();
|
||||
loadConfigs();
|
||||
}
|
||||
|
||||
document.getElementById('create-modal').addEventListener('click', function(e) { if (e.target === this) closeCreateModal(); });
|
||||
document.getElementById('rename-modal').addEventListener('click', function(e) { if (e.target === this) closeRenameModal(); });
|
||||
document.getElementById('new-variant-modal').addEventListener('click', function(e) { if (e.target === this) closeNewVariantModal(); });
|
||||
document.getElementById('clone-modal').addEventListener('click', function(e) { if (e.target === this) closeCloneModal(); });
|
||||
document.getElementById('import-modal').addEventListener('click', function(e) { if (e.target === this) closeImportModal(); });
|
||||
document.getElementById('project-settings-modal').addEventListener('click', function(e) { if (e.target === this) closeProjectSettingsModal(); });
|
||||
document.getElementById('transfer-modal').addEventListener('click', function(e) { if (e.target === this) closeTransferModal(); });
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') {
|
||||
closeCreateModal();
|
||||
@@ -770,6 +870,7 @@ document.addEventListener('keydown', function(e) {
|
||||
closeCloneModal();
|
||||
closeImportModal();
|
||||
closeProjectSettingsModal();
|
||||
closeTransferModal();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -777,6 +878,7 @@ document.addEventListener('DOMContentLoaded', async function() {
|
||||
applyStatusModeUI();
|
||||
const ok = await loadProject();
|
||||
if (!ok) return;
|
||||
updateDeleteVariantButton();
|
||||
await loadConfigs();
|
||||
});
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user