Add project variants and UI updates

This commit is contained in:
Mikhail Chusavitin
2026-02-13 19:27:48 +03:00
parent 4e1a46bd71
commit 9b5d57902d
23 changed files with 1113 additions and 147 deletions

View File

@@ -4,23 +4,43 @@
<div class="space-y-4">
<div class="flex items-center justify-between gap-3">
<div class="flex items-center gap-3">
<a href="/projects" class="text-gray-500 hover:text-gray-700" title="Назад к проектам">
<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="M15 19l-7-7 7-7"></path>
<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>
<h1 class="text-2xl font-bold" id="project-title">Проект</h1>
<div class="text-2xl font-bold flex items-center gap-2">
<a id="project-code-link" href="/projects" class="text-blue-700 hover:underline">
<span id="project-code"></span>
</a>
<span class="text-gray-400">-</span>
<div class="relative">
<button id="project-variant-button" type="button" class="inline-flex items-center gap-2 text-base font-medium px-3 py-1.5 rounded-lg bg-gray-100 hover:bg-gray-200 border border-gray-200">
<span id="project-variant-label">main</span>
<svg class="w-4 h-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
</svg>
</button>
<div id="project-variant-menu" class="absolute left-0 mt-2 min-w-[10rem] rounded-lg border border-gray-200 bg-white shadow-lg hidden z-10">
<div id="project-variant-list" class="py-1"></div>
</div>
</div>
</div>
</div>
</div>
<div id="action-buttons" class="mt-4 grid grid-cols-1 sm:grid-cols-3 gap-3">
<button onclick="openCreateModal()" class="py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium">
<div id="action-buttons" class="mt-4 grid grid-cols-1 sm:grid-cols-4 gap-3">
<button onclick="openNewVariantModal()" class="py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 font-medium">
+ Новый вариант
</button>
<button onclick="openCreateModal()" class="py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium">
+ Создать новую квоту
</button>
<button onclick="openImportModal()" class="py-3 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 font-medium">
<button onclick="openImportModal()" class="py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 font-medium">
Импорт квоты
</button>
<button onclick="openProjectSettingsModal()" class="py-3 bg-gray-700 text-white rounded-lg hover:bg-gray-800 font-medium">
<button onclick="openProjectSettingsModal()" class="py-2 bg-gray-700 text-white rounded-lg hover:bg-gray-800 font-medium">
Параметры
</button>
</div>
@@ -61,6 +81,33 @@
</div>
</div>
<div id="new-variant-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-lg 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>
<div id="new-variant-code" class="px-3 py-2 bg-gray-50 border rounded text-sm text-gray-700"></div>
</div>
<div>
<label for="new-variant-name" class="block text-sm font-medium text-gray-700 mb-1">Название (необязательно)</label>
<input id="new-variant-name" type="text" placeholder="Например: Lenovo"
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
</div>
<div>
<label for="new-variant-value" class="block text-sm font-medium text-gray-700 mb-1">Вариант</label>
<input id="new-variant-value" type="text" placeholder="Например: Lenovo"
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<div class="text-xs text-gray-500 mt-1">Оставьте пустым для main нельзя — нужно уникальное значение.</div>
</div>
</div>
<div class="mt-6 flex justify-end gap-2">
<button onclick="closeNewVariantModal()" class="px-4 py-2 text-gray-700 bg-gray-100 rounded hover:bg-gray-200">Отмена</button>
<button onclick="createNewVariant()" class="px-4 py-2 text-white bg-purple-600 rounded hover:bg-purple-700">Создать</button>
</div>
</div>
</div>
<div id="rename-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>
@@ -120,6 +167,16 @@
<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="project-settings-code"
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Вариант (необязательно)</label>
<input type="text" id="project-settings-variant"
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Название проекта</label>
<input type="text" id="project-settings-name"
@@ -144,6 +201,8 @@ const projectUUID = '{{.ProjectUUID}}';
let configStatusMode = 'active';
let project = null;
let allConfigs = [];
let projectVariants = [];
let variantMenuInitialized = false;
function escapeHtml(text) {
const div = document.createElement('div');
@@ -157,6 +216,91 @@ function resolveProjectTrackerURL(projectData) {
return explicitURL;
}
function formatProjectTitle(projectData) {
if (!projectData) return 'Проект';
const code = (projectData.code || '').trim();
const name = (projectData.name || '').trim();
const variant = (projectData.variant || '').trim();
if (!code) return name || 'Проект';
if (variant) {
return code + ': (' + variant + ') ' + (name || '');
}
return code + ': ' + (name || '');
}
function normalizeVariantLabel(variant) {
const trimmed = (variant || '').trim();
return trimmed === '' ? 'main' : trimmed;
}
async function loadVariantsForCode(code) {
if (!code) return;
try {
const resp = await fetch('/api/projects/all');
if (!resp.ok) return;
const data = await resp.json();
const allProjects = Array.isArray(data) ? data : (data.projects || []);
projectVariants = allProjects
.filter(p => (p.code || '').trim() === code)
.map(p => ({uuid: p.uuid, variant: (p.variant || '').trim()}));
projectVariants.sort((a, b) => normalizeVariantLabel(a.variant).localeCompare(normalizeVariantLabel(b.variant)));
} catch (e) {
// ignore
}
}
function renderVariantSelect() {
const list = document.getElementById('project-variant-list');
const menu = document.getElementById('project-variant-menu');
const button = document.getElementById('project-variant-button');
const label = document.getElementById('project-variant-label');
const codeLink = document.getElementById('project-code-link');
if (!list || !menu || !button || !label) return;
list.innerHTML = '';
const variants = projectVariants.length ? projectVariants : [{uuid: projectUUID, variant: (project && project.variant) || ''}];
let mainUUID = '';
variants.forEach(item => {
const variantLabel = normalizeVariantLabel(item.variant);
if (variantLabel === 'main' && !mainUUID) {
mainUUID = item.uuid;
}
const option = document.createElement('button');
option.type = 'button';
option.className = 'w-full text-left px-3 py-2 text-sm hover:bg-gray-50';
if (item.uuid === projectUUID) {
option.className += ' font-semibold text-gray-900';
label.textContent = variantLabel;
}
option.textContent = variantLabel;
option.onclick = function() {
menu.classList.add('hidden');
if (item.uuid && item.uuid !== projectUUID) {
window.location.href = '/projects/' + item.uuid;
}
};
list.appendChild(option);
});
if (codeLink) {
const targetMain = mainUUID || projectUUID;
codeLink.href = '/projects/' + targetMain;
}
if (!variantMenuInitialized) {
button.onclick = function(e) {
e.stopPropagation();
menu.classList.toggle('hidden');
};
document.addEventListener('click', function() {
menu.classList.add('hidden');
});
menu.addEventListener('click', function(e) {
e.stopPropagation();
});
variantMenuInitialized = true;
}
}
function setConfigStatusMode(mode) {
if (mode !== 'active' && mode !== 'archived') return;
configStatusMode = mode;
@@ -254,7 +398,9 @@ async function loadProject() {
return false;
}
project = await resp.json();
document.getElementById('project-title').textContent = project.name;
document.getElementById('project-code').textContent = project.code || '—';
await loadVariantsForCode(project.code || '');
renderVariantSelect();
const trackerLink = document.getElementById('tracker-link');
if (trackerLink) {
if (project && project.is_system) {
@@ -297,6 +443,56 @@ function openCreateModal() {
document.getElementById('create-name').focus();
}
function openNewVariantModal() {
if (!project) return;
document.getElementById('new-variant-code').textContent = (project.code || '').trim() || '—';
document.getElementById('new-variant-name').value = project.name || '';
document.getElementById('new-variant-value').value = '';
document.getElementById('new-variant-modal').classList.remove('hidden');
document.getElementById('new-variant-modal').classList.add('flex');
document.getElementById('new-variant-value').focus();
}
function closeNewVariantModal() {
document.getElementById('new-variant-modal').classList.add('hidden');
document.getElementById('new-variant-modal').classList.remove('flex');
}
async function createNewVariant() {
if (!project) return;
const code = (project.code || '').trim();
const variant = (document.getElementById('new-variant-value').value || '').trim();
const nameRaw = (document.getElementById('new-variant-name').value || '').trim();
if (!code || !variant) {
showToast('Укажите вариант', 'error');
return;
}
const payload = {
code: code,
variant: variant,
name: nameRaw ? nameRaw : null
};
const resp = await fetch('/api/projects', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(payload)
});
if (!resp.ok) {
const data = await resp.json().catch(() => ({}));
showToast(data.error || 'Ошибка создания варианта', 'error');
return;
}
const created = await resp.json().catch(() => null);
closeNewVariantModal();
showToast('Вариант создан', 'success');
if (created && created.uuid) {
window.location.href = '/projects/' + created.uuid;
return;
}
loadProject();
loadConfigs();
}
function closeCreateModal() {
document.getElementById('create-modal').classList.add('hidden');
document.getElementById('create-modal').classList.remove('flex');
@@ -429,6 +625,8 @@ function openProjectSettingsModal() {
alert('Системный проект нельзя редактировать');
return;
}
document.getElementById('project-settings-code').value = project.code || '';
document.getElementById('project-settings-variant').value = project.variant || '';
document.getElementById('project-settings-name').value = project.name || '';
document.getElementById('project-settings-tracker-url').value = (project.tracker_url || '').trim();
document.getElementById('project-settings-modal').classList.remove('hidden');
@@ -442,27 +640,31 @@ function closeProjectSettingsModal() {
async function saveProjectSettings() {
if (!project) return;
const code = document.getElementById('project-settings-code').value.trim();
const variant = document.getElementById('project-settings-variant').value.trim();
const name = document.getElementById('project-settings-name').value.trim();
const trackerURL = document.getElementById('project-settings-tracker-url').value.trim();
if (!name) {
alert('Введите название проекта');
if (!code) {
alert('Введите код проекта');
return;
}
const resp = await fetch('/api/projects/' + projectUUID, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({name: name, tracker_url: trackerURL})
body: JSON.stringify({code: code, variant: variant, name: name, tracker_url: trackerURL})
});
if (!resp.ok) {
if (resp.status === 409) {
alert('Проект с таким названием уже существует');
alert('Проект с таким кодом и вариантом уже существует');
return;
}
alert('Не удалось сохранить параметры проекта');
return;
}
project = await resp.json();
document.getElementById('project-title').textContent = project.name;
document.getElementById('project-code').textContent = project.code || '—';
await loadVariantsForCode(project.code || '');
renderVariantSelect();
const trackerLink = document.getElementById('tracker-link');
if (trackerLink) {
const trackerURLResolved = resolveProjectTrackerURL(project);
@@ -557,6 +759,7 @@ function wildcardMatch(value, pattern) {
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(); });