fix(qfs): project ui, config naming, sync timestamps - v1.5.4

This commit is contained in:
Mikhail Chusavitin
2026-03-16 08:32:15 +03:00
parent c599897142
commit 35c5600b36
16 changed files with 815 additions and 93 deletions

View File

@@ -29,23 +29,26 @@
<button onclick="openNewVariantModal()" class="inline-flex w-full sm:w-auto justify-center items-center px-3 py-1.5 text-sm font-medium bg-purple-600 text-white rounded-lg hover:bg-purple-700">
+ Вариант
</button>
<button onclick="openVariantActionModal()" class="inline-flex w-full sm:w-auto justify-center items-center px-3 py-1.5 text-sm font-medium bg-indigo-600 text-white rounded-lg hover:bg-indigo-700">
Действия с вариантом
</button>
</div>
</div>
</div>
<div id="action-buttons" class="mt-4 grid grid-cols-1 sm:grid-cols-6 gap-3">
<button onclick="openCreateModal()" class="py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium">
Новая конфигурация
+ Конфигурация
</button>
<button onclick="openVendorImportModal()" class="py-2 bg-amber-600 text-white rounded-lg hover:bg-amber-700 font-medium">
Импорт выгрузки вендора
</button>
<button onclick="openProjectSettingsModal()" class="py-2 bg-gray-700 text-white rounded-lg hover:bg-gray-800 font-medium">
Параметры
Импорт
</button>
<button onclick="openExportModal()" class="py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 font-medium">
Экспорт CSV
</button>
<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>
@@ -173,6 +176,34 @@
</div>
</div>
<div id="variant-action-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>
<input type="text" id="variant-action-name"
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500">
</div>
<label class="flex items-center gap-2 text-sm text-gray-700">
<input type="checkbox" id="variant-action-copy" class="rounded border-gray-300">
Создать копию
</label>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Код проекта</label>
<input type="text" id="variant-action-code"
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500">
</div>
<input type="hidden" id="variant-action-current-name">
<input type="hidden" id="variant-action-current-code">
</div>
<div class="flex justify-end space-x-3 mt-6">
<button onclick="closeVariantActionModal()" class="px-4 py-2 text-gray-600 hover:text-gray-800">Отмена</button>
<button onclick="saveVariantAction()" class="px-4 py-2 bg-indigo-600 text-white rounded hover:bg-indigo-700">Сохранить</button>
</div>
</div>
</div>
<div id="config-action-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>
@@ -540,6 +571,213 @@ function closeNewVariantModal() {
document.getElementById('new-variant-modal').classList.remove('flex');
}
function openVariantActionModal() {
if (!project) return;
const currentName = (project.variant || '').trim();
const currentCode = (project.code || '').trim();
document.getElementById('variant-action-current-name').value = currentName;
document.getElementById('variant-action-current-code').value = currentCode;
document.getElementById('variant-action-name').value = currentName;
document.getElementById('variant-action-code').value = currentCode;
document.getElementById('variant-action-copy').checked = false;
document.getElementById('variant-action-modal').classList.remove('hidden');
document.getElementById('variant-action-modal').classList.add('flex');
const nameInput = document.getElementById('variant-action-name');
nameInput.focus();
nameInput.select();
}
function closeVariantActionModal() {
document.getElementById('variant-action-modal').classList.add('hidden');
document.getElementById('variant-action-modal').classList.remove('flex');
}
function findUniqueVariantActionName(baseName, targetCode, excludeProjectUUID) {
const cleanedBase = (baseName || '').trim();
if (!cleanedBase || normalizeVariantLabel(cleanedBase).toLowerCase() === 'main') {
return {error: 'Имя варианта не должно быть пустым и не может быть main'};
}
const code = (targetCode || '').trim();
const used = new Set(
projectsCatalog
.filter(p => (p.code || '').trim().toLowerCase() === code.toLowerCase())
.filter(p => !excludeProjectUUID || p.uuid !== excludeProjectUUID)
.map(p => ((p.variant || '').trim()).toLowerCase())
);
if (!used.has(cleanedBase.toLowerCase())) {
return {name: cleanedBase, changed: false};
}
let candidate = cleanedBase + '_копия';
let suffix = 2;
while (used.has(candidate.toLowerCase())) {
candidate = cleanedBase + '_копия' + suffix;
suffix++;
}
return {name: candidate, changed: true};
}
async function resolveUniqueConfigActionName(baseName, targetProjectUUID, excludeConfigUUID) {
const cleanedBase = (baseName || '').trim();
if (!cleanedBase) {
return {error: 'Введите название'};
}
let configs = [];
if (targetProjectUUID === projectUUID) {
configs = Array.isArray(allConfigs) ? allConfigs : [];
} else {
const resp = await fetch('/api/projects/' + targetProjectUUID + '/configs?status=all');
if (!resp.ok) {
return {error: 'Не удалось проверить конфигурации целевого проекта'};
}
const data = await resp.json().catch(() => ({}));
configs = Array.isArray(data.configurations) ? data.configurations : [];
}
const used = new Set(
configs
.filter(cfg => !excludeConfigUUID || cfg.uuid !== excludeConfigUUID)
.map(cfg => (cfg.name || '').trim().toLowerCase())
)
if (!used.has(cleanedBase.toLowerCase())) {
return {name: cleanedBase, changed: false};
}
let candidate = cleanedBase + '_копия';
let suffix = 2;
while (used.has(candidate.toLowerCase())) {
candidate = cleanedBase + '_копия' + suffix;
suffix++;
}
return {name: candidate, changed: true};
}
async function cloneVariantConfigurations(targetProjectUUID) {
const listResp = await fetch('/api/projects/' + projectUUID + '/configs');
if (!listResp.ok) {
throw new Error('Не удалось загрузить конфигурации варианта');
}
const listData = await listResp.json().catch(() => ({}));
const configs = Array.isArray(listData.configurations) ? listData.configurations : [];
for (const cfg of configs) {
const cloneResp = await fetch('/api/projects/' + targetProjectUUID + '/configs/' + cfg.uuid + '/clone', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({name: cfg.name})
});
if (!cloneResp.ok) {
throw new Error('Не удалось скопировать конфигурацию «' + (cfg.name || 'без названия') + '»');
}
}
}
async function saveVariantAction() {
if (!project) return;
const notify = (message, type) => {
if (typeof showToast === 'function') {
showToast(message, type || 'success');
} else {
alert(message);
}
};
const currentName = document.getElementById('variant-action-current-name').value.trim();
const currentCode = document.getElementById('variant-action-current-code').value.trim();
const rawName = document.getElementById('variant-action-name').value.trim();
const code = document.getElementById('variant-action-code').value.trim();
const copy = document.getElementById('variant-action-copy').checked;
if (!code) {
notify('Введите код проекта', 'error');
return;
}
const uniqueNameResult = findUniqueVariantActionName(rawName, code, copy ? '' : projectUUID);
if (uniqueNameResult.error) {
notify(uniqueNameResult.error, 'error');
return;
}
const name = uniqueNameResult.name;
if (uniqueNameResult.changed) {
document.getElementById('variant-action-name').value = name;
notify('Имя варианта занято, использовано ' + name, 'success');
}
if (copy) {
const createResp = await fetch('/api/projects', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
code: code,
variant: name,
name: project.name || null,
tracker_url: (project.tracker_url || '').trim()
})
});
if (!createResp.ok) {
if (createResp.status === 400) {
notify('Имя варианта не может быть main', 'error');
return;
}
if (createResp.status === 409) {
notify('Вариант с таким кодом и значением уже существует', 'error');
return;
}
notify('Не удалось создать копию варианта', 'error');
return;
}
const created = await createResp.json().catch(() => null);
if (!created || !created.uuid) {
notify('Не удалось создать копию варианта', 'error');
return;
}
try {
await cloneVariantConfigurations(created.uuid);
} catch (err) {
notify(err.message || 'Вариант создан, но конфигурации не скопированы полностью', 'error');
window.location.href = '/projects/' + created.uuid;
return;
}
closeVariantActionModal();
notify('Копия варианта создана', 'success');
window.location.href = '/projects/' + created.uuid;
return;
}
const changed = name !== currentName || code !== currentCode;
if (!changed) {
closeVariantActionModal();
return;
}
const updateResp = await fetch('/api/projects/' + projectUUID, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({code: code, variant: name})
});
if (!updateResp.ok) {
if (updateResp.status === 400) {
notify('Имя варианта не может быть main', 'error');
return;
}
if (updateResp.status === 409) {
notify('Вариант с таким кодом и значением уже существует', 'error');
return;
}
notify('Не удалось сохранить вариант', 'error');
return;
}
closeVariantActionModal();
await loadProject();
await loadConfigs();
updateDeleteVariantButton();
notify('Вариант обновлён', 'success');
}
async function createNewVariant() {
if (!project) return;
const code = (project.code || '').trim();
@@ -864,12 +1102,22 @@ async function saveConfigAction() {
notify('Введите название', 'error');
return;
}
const uniqueNameResult = await resolveUniqueConfigActionName(name, targetProjectUUID, copy ? '' : uuid);
if (uniqueNameResult.error) {
notify(uniqueNameResult.error, 'error');
return;
}
const resolvedName = uniqueNameResult.name;
if (uniqueNameResult.changed) {
document.getElementById('config-action-name').value = resolvedName;
notify('Имя занято, использовано ' + resolvedName, 'success');
}
if (copy) {
const cloneResp = await fetch('/api/projects/' + targetProjectUUID + '/configs/' + uuid + '/clone', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({name: name})
body: JSON.stringify({name: resolvedName})
});
if (!cloneResp.ok) {
notify('Не удалось скопировать конфигурацию', 'error');
@@ -886,11 +1134,11 @@ async function saveConfigAction() {
}
let changed = false;
if (name !== currentName) {
if (resolvedName !== currentName) {
const renameResp = await fetch('/api/configs/' + uuid + '/rename', {
method: 'PATCH',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({name: name})
body: JSON.stringify({name: resolvedName})
});
if (!renameResp.ok) {
notify('Не удалось переименовать конфигурацию', 'error');
@@ -1016,6 +1264,7 @@ function updateDeleteVariantButton() {
document.getElementById('create-modal').addEventListener('click', function(e) { if (e.target === this) closeCreateModal(); });
document.getElementById('vendor-import-modal').addEventListener('click', function(e) { if (e.target === this) closeVendorImportModal(); });
document.getElementById('new-variant-modal').addEventListener('click', function(e) { if (e.target === this) closeNewVariantModal(); });
document.getElementById('variant-action-modal').addEventListener('click', function(e) { if (e.target === this) closeVariantActionModal(); });
document.getElementById('config-action-modal').addEventListener('click', function(e) { if (e.target === this) closeConfigActionModal(); });
document.getElementById('project-settings-modal').addEventListener('click', function(e) { if (e.target === this) closeProjectSettingsModal(); });
document.getElementById('config-action-project-input').addEventListener('input', function(e) {
@@ -1026,7 +1275,7 @@ document.getElementById('config-action-copy').addEventListener('change', functio
const currentName = document.getElementById('config-action-current-name').value;
const nameInput = document.getElementById('config-action-name');
if (e.target.checked && nameInput.value.trim() === currentName.trim()) {
nameInput.value = currentName + ' (копия)';
nameInput.value = currentName + '_копия';
}
syncActionModalMode();
});
@@ -1034,6 +1283,7 @@ document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
closeCreateModal();
closeVendorImportModal();
closeVariantActionModal();
closeConfigActionModal();
closeProjectSettingsModal();
}