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

@@ -20,7 +20,7 @@
</div>
<div class="max-w-md">
<input id="projects-search" type="text" placeholder="Поиск проекта по названию"
<input id="projects-search" type="text" placeholder="Поиск проекта по названию или коду"
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
</div>
@@ -31,11 +31,21 @@
<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 for="create-project-name" class="block text-sm font-medium text-gray-700 mb-1">Название проекта</label>
<input id="create-project-name" type="text" placeholder="Например: Инфраструктура для OPS-123"
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
</div>
<div>
<label for="create-project-code" class="block text-sm font-medium text-gray-700 mb-1">Код проекта</label>
<input id="create-project-code" type="text" placeholder="Например: OPS-123"
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
</div>
<div>
<label for="create-project-variant" class="block text-sm font-medium text-gray-700 mb-1">Вариант (необязательно)</label>
<input id="create-project-variant" 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="create-project-tracker-url" class="block text-sm font-medium text-gray-700 mb-1">Ссылка на трекер</label>
<input id="create-project-tracker-url" type="url" placeholder="https://tracker.yandex.ru/OPS-123"
@@ -59,6 +69,8 @@ let sortField = 'created_at';
let sortDir = 'desc';
let createProjectTrackerManuallyEdited = false;
let createProjectLastAutoTrackerURL = '';
let variantsByCode = {};
let variantsLoaded = false;
const trackerBaseURL = 'https://tracker.yandex.ru/';
@@ -85,6 +97,55 @@ function formatDateTime(value) {
});
}
function normalizeVariant(variant) {
const trimmed = (variant || '').trim();
return trimmed === '' ? 'main' : trimmed;
}
function renderVariantChips(code, fallbackVariant, fallbackUUID) {
const variants = variantsByCode[code || ''] || [];
if (!variants.length) {
const single = normalizeVariant(fallbackVariant);
const href = fallbackUUID ? ('/projects/' + fallbackUUID) : '/projects';
return '<a href="' + href + '" class="inline-flex items-center px-2 py-0.5 text-xs rounded-full bg-gray-100 text-gray-600 hover:bg-gray-200 hover:text-gray-900">' + escapeHtml(single) + '</a>';
}
return variants.map(v => {
const href = v.uuid ? ('/projects/' + v.uuid) : '/projects';
return '<a href="' + href + '" class="inline-flex items-center px-2 py-0.5 text-xs rounded-full bg-gray-100 text-gray-700 hover:bg-gray-200 hover:text-gray-900">' + escapeHtml(v.label) + '</a>';
}).join(' ');
}
async function loadVariantsIndex() {
if (variantsLoaded) 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 || []);
variantsByCode = {};
allProjects.forEach(p => {
const code = (p.code || '').trim();
const variant = normalizeVariant(p.variant);
if (!variantsByCode[code]) {
variantsByCode[code] = [];
}
if (!variantsByCode[code].some(v => v.label === variant)) {
variantsByCode[code].push({label: variant, uuid: p.uuid});
}
});
Object.keys(variantsByCode).forEach(code => {
variantsByCode[code].sort((a, b) => {
if (a.label === 'main') return -1;
if (b.label === 'main') return 1;
return a.label.localeCompare(b.label);
});
});
variantsLoaded = true;
} catch (e) {
// ignore
}
}
function toggleSort(field) {
if (sortField === field) {
sortDir = sortDir === 'asc' ? 'desc' : 'asc';
@@ -132,10 +193,33 @@ async function loadProjects() {
}
const data = await resp.json();
rows = data.projects || [];
if (Array.isArray(rows) && rows.length) {
const byCode = {};
rows.forEach(p => {
const codeKey = (p.code || '').trim();
if (!codeKey) {
const fallbackKey = p.uuid || Math.random().toString(36);
byCode[fallbackKey] = p;
return;
}
const variant = (p.variant || '').trim();
if (!byCode[codeKey]) {
byCode[codeKey] = p;
return;
}
const current = byCode[codeKey];
const currentVariant = (current.variant || '').trim();
if (currentVariant !== '' && variant === '') {
byCode[codeKey] = p;
}
});
rows = Object.values(byCode);
}
total = data.total || 0;
totalPages = data.total_pages || 0;
page = data.page || currentPage;
currentPage = page;
await loadVariantsIndex();
} catch (e) {
root.innerHTML = '<div class="text-red-600">Ошибка загрузки проектов: ' + escapeHtml(String(e.message || e)) + '</div>';
return;
@@ -144,27 +228,22 @@ async function loadProjects() {
let html = '<div class="overflow-x-auto"><table class="w-full">';
html += '<thead class="bg-gray-50">';
html += '<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">';
html += '<button type="button" onclick="toggleSort(\'name\')" class="inline-flex items-center gap-1 hover:text-gray-700">Название проекта';
html += '<button type="button" onclick="toggleSort(\'name\')" class="inline-flex items-center gap-1 hover:text-gray-700">Название';
if (sortField === 'name') {
html += sortDir === 'asc' ? ' <span>↑</span>' : ' <span>↓</span>';
}
html += '</button></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">';
html += '<button type="button" onclick="toggleSort(\'created_at\')" class="inline-flex items-center gap-1 hover:text-gray-700">Создан';
if (sortField === 'created_at') {
html += sortDir === 'asc' ? ' <span>↑</span>' : ' <span>↓</span>';
}
html += '</button></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-right 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>';
html += '<tr>';
html += '<th class="px-4 py-2"></th>';
html += '<th class="px-4 py-2"><input id="projects-author-filter" type="text" value="' + escapeHtml(authorSearch) + '" placeholder="Фильтр автора" class="w-full px-2 py-1 border rounded text-xs focus:ring-1 focus:ring-blue-500 focus:border-blue-500"></th>';
html += '<th class="px-4 py-2"></th>';
html += '<th class="px-4 py-2"><input id="projects-author-filter" type="text" value="' + escapeHtml(authorSearch) + '" placeholder="Фильтр автора" class="w-full px-2 py-1 border rounded text-xs focus:ring-1 focus:ring-blue-500 focus:border-blue-500"></th>';
html += '<th class="px-4 py-2"></th>';
html += '<th class="px-4 py-2"></th>';
html += '<th class="px-4 py-2"></th>';
@@ -175,21 +254,28 @@ async function loadProjects() {
html += '<tr><td colspan="6" class="px-4 py-6 text-sm text-gray-500 text-center">Проектов нет</td></tr>';
}
rows.forEach(p => {
html += '<tr class="hover:bg-gray-50">';
html += '<td class="px-4 py-3 text-sm font-medium"><a class="text-blue-600 hover:underline" href="/projects/' + p.uuid + '">' + escapeHtml(p.name) + '</a></td>';
html += '<td class="px-4 py-3 text-sm text-gray-600">' + escapeHtml(p.owner_username || '—') + '</td>';
html += '<td class="px-4 py-3 text-sm text-gray-600">' + escapeHtml(formatDateTime(p.created_at)) + '</td>';
html += '<td class="px-4 py-3 text-sm text-right text-gray-700">' + (p.config_count || 0) + '</td>';
html += '<td class="px-4 py-3 text-sm text-right text-gray-700">' + formatMoney(p.total) + '</td>';
rows.forEach(p => {
html += '<tr class="hover:bg-gray-50">';
const displayName = p.name || '';
const createdBy = p.owner_username || '—';
const updatedBy = '';
const createdLabel = formatDateTime(p.created_at) + ' @ ' + createdBy;
const updatedLabel = formatDateTime(p.updated_at) + ' @ ' + updatedBy;
const variantChips = renderVariantChips(p.code, p.variant, p.uuid);
html += '<td class="px-4 py-3 text-sm font-medium"><a class="text-blue-600 hover:underline" href="/projects/' + p.uuid + '">' + escapeHtml(p.code || '—') + '</a></td>';
html += '<td class="px-4 py-3 text-sm text-gray-700">' + escapeHtml(displayName) + '</td>';
html += '<td class="px-4 py-3 text-sm text-gray-600">' + escapeHtml(createdLabel) + '</td>';
html += '<td class="px-4 py-3 text-sm text-gray-600">' + escapeHtml(updatedLabel) + '</td>';
html += '<td class="px-4 py-3 text-sm">' + variantChips + '</td>';
html += '<td class="px-4 py-3 text-sm text-right"><div class="inline-flex items-center gap-2">';
if (p.is_active) {
html += '<button onclick="copyProject(\'' + p.uuid + '\', \'' + escapeHtml(p.name).replace(/'/g, "\\'") + '\')" class="text-green-700 hover:text-green-900" title="Копировать">';
const safeName = escapeHtml(displayName).replace(/'/g, "\\'");
html += '<button onclick="copyProject(' + JSON.stringify(p.uuid) + ', ' + JSON.stringify(displayName) + ')" class="text-green-700 hover:text-green-900" 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>';
html += '</button>';
html += '<button onclick="renameProject(\'' + p.uuid + '\', \'' + escapeHtml(p.name).replace(/'/g, "\\'") + '\')" class="text-blue-700 hover:text-blue-900" title="Переименовать">';
html += '<button onclick="renameProject(' + JSON.stringify(p.uuid) + ', ' + JSON.stringify(displayName) + ')" class="text-blue-700 hover:text-blue-900" 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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path></svg>';
html += '</button>';
@@ -251,15 +337,19 @@ function buildTrackerURLFromProjectCode(projectCode) {
}
function openCreateProjectModal() {
const nameInput = document.getElementById('create-project-name');
const codeInput = document.getElementById('create-project-code');
const variantInput = document.getElementById('create-project-variant');
const trackerInput = document.getElementById('create-project-tracker-url');
nameInput.value = '';
codeInput.value = '';
variantInput.value = '';
trackerInput.value = '';
createProjectTrackerManuallyEdited = false;
createProjectLastAutoTrackerURL = '';
document.getElementById('create-project-modal').classList.remove('hidden');
document.getElementById('create-project-modal').classList.add('flex');
codeInput.focus();
nameInput.focus();
}
function closeCreateProjectModal() {
@@ -278,10 +368,14 @@ function updateCreateProjectTrackerURL() {
}
async function createProject() {
const nameInput = document.getElementById('create-project-name');
const codeInput = document.getElementById('create-project-code');
const variantInput = document.getElementById('create-project-variant');
const trackerInput = document.getElementById('create-project-tracker-url');
const name = (codeInput.value || '').trim();
if (!name) {
const name = (nameInput.value || '').trim();
const code = (codeInput.value || '').trim();
const variant = (variantInput.value || '').trim();
if (!code) {
alert('Введите код проекта');
return;
}
@@ -290,12 +384,14 @@ async function createProject() {
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
name: name,
code: code,
variant: variant,
tracker_url: (trackerInput.value || '').trim()
})
});
if (!resp.ok) {
if (resp.status === 409) {
alert('Проект с таким названием уже существует');
alert('Проект с таким кодом и вариантом уже существует');
return;
}
alert('Не удалось создать проект');
@@ -361,15 +457,18 @@ async function addConfigToProject(projectUUID) {
async function copyProject(projectUUID, projectName) {
const newName = prompt('Название копии проекта', projectName + ' (копия)');
if (!newName || !newName.trim()) return;
const newCode = prompt('Код проекта', '');
if (!newCode || !newCode.trim()) return;
const newVariant = prompt('Вариант (необязательно)', '');
const createResp = await fetch('/api/projects', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({name: newName.trim()})
body: JSON.stringify({name: newName.trim(), code: newCode.trim(), variant: (newVariant || '').trim()})
});
if (!createResp.ok) {
if (createResp.status === 409) {
alert('Проект с таким названием уже существует');
alert('Проект с таким кодом и вариантом уже существует');
return;
}
alert('Не удалось создать копию проекта');
@@ -410,6 +509,13 @@ document.addEventListener('DOMContentLoaded', function() {
updateCreateProjectTrackerURL();
});
document.getElementById('create-project-name').addEventListener('keydown', function(e) {
if (e.key === 'Enter') {
e.preventDefault();
createProject();
}
});
document.getElementById('create-project-tracker-url').addEventListener('input', function(e) {
createProjectTrackerManuallyEdited = (e.target.value || '').trim() !== createProjectLastAutoTrackerURL;
});