Files
QuoteForge/web/templates/project_detail.html
2026-02-25 17:19:26 +03:00

1180 lines
52 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{{define "title"}}Проект - QuoteForge{{end}}
{{define "content"}}
<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="Все проекты">
<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">
<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-6 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-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-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="exportProject()" class="py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 font-medium">
Экспорт CSV
</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">
открыть в трекере
</a>
</div>
<div class="mt-4 inline-flex rounded-lg border border-gray-200 overflow-hidden">
<button id="status-active-btn" onclick="setConfigStatusMode('active')" class="px-4 py-2 text-sm font-medium bg-blue-600 text-white">
Активные
</button>
<button id="status-archived-btn" onclick="setConfigStatusMode('archived')" class="px-4 py-2 text-sm font-medium bg-white text-gray-700 hover:bg-gray-50 border-l border-gray-200">
Архив
</button>
</div>
<div id="configs-list">
<div class="text-center py-8 text-gray-500">Загрузка...</div>
</div>
</div>
<div id="create-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="create-name" placeholder="Например: OPP-2026-001"
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
</div>
</div>
<div class="flex justify-end space-x-3 mt-6">
<button onclick="closeCreateModal()" class="px-4 py-2 text-gray-600 hover:text-gray-800">Отмена</button>
<button onclick="createConfig()" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">Создать</button>
</div>
</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="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>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Название</label>
<input type="text" id="config-action-name"
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
</div>
<label class="flex items-center gap-2 text-sm text-gray-700">
<input type="checkbox" id="config-action-copy" class="rounded border-gray-300">
Создать копию
</label>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Проект</label>
<input id="config-action-project-input"
list="config-action-project-options"
placeholder="Начните вводить проект"
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<datalist id="config-action-project-options"></datalist>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Вариант</label>
<input id="config-action-variant-input"
list="config-action-variant-options"
placeholder="Выберите вариант"
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<datalist id="config-action-variant-options"></datalist>
</div>
<input type="hidden" id="config-action-uuid">
<input type="hidden" id="config-action-current-name">
<input type="hidden" id="config-action-current-project">
</div>
<div class="flex justify-end space-x-3 mt-6">
<button onclick="closeConfigActionModal()" class="px-4 py-2 text-gray-600 hover:text-gray-800">Отмена</button>
<button onclick="saveConfigAction()" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">Сохранить</button>
</div>
</div>
</div>
<div id="import-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-3">
<label class="block text-sm font-medium text-gray-700">Квота</label>
<input id="import-config-input"
list="import-config-options"
placeholder="Начните вводить название квоты"
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<datalist id="import-config-options"></datalist>
<div class="text-xs text-gray-500">Квота будет перемещена в текущий проект.</div>
</div>
<div class="flex justify-end gap-2 mt-6">
<button onclick="closeImportModal()" class="px-4 py-2 text-gray-600 hover:text-gray-800">Отмена</button>
<button onclick="importConfigToProject()" class="px-4 py-2 bg-indigo-600 text-white rounded hover:bg-indigo-700">Импортировать</button>
</div>
</div>
</div>
<div id="project-settings-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="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"
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-tracker-url" placeholder="https://tracker.example.com/PROJ-123"
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">Оставьте пустым, чтобы скрыть ссылку.</div>
</div>
</div>
<div class="flex justify-end space-x-3 mt-6">
<button onclick="closeProjectSettingsModal()" class="px-4 py-2 text-gray-600 hover:text-gray-800">Отмена</button>
<button onclick="saveProjectSettings()" 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';
let project = null;
let allConfigs = [];
let dragConfigUUID = '';
let isReorderingConfigs = false;
let projectVariants = [];
let projectsCatalog = [];
let variantMenuInitialized = false;
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text || '';
return div.innerHTML;
}
function formatMoneyNoDecimals(value) {
const safe = Number.isFinite(Number(value)) ? Number(value) : 0;
return '$' + Math.round(safe).toLocaleString('en-US');
}
function resolveProjectTrackerURL(projectData) {
if (!projectData) return '';
const explicitURL = (projectData.tracker_url || '').trim();
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) {
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 || []);
projectsCatalog = allProjects.filter(p => p && p.uuid && p.is_active !== false);
if (!code) {
projectVariants = [];
return;
}
projectVariants = allProjects
.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) {
// 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;
applyStatusModeUI();
loadConfigs();
}
function applyStatusModeUI() {
const activeBtn = document.getElementById('status-active-btn');
const archivedBtn = document.getElementById('status-archived-btn');
const actionButtons = document.getElementById('action-buttons');
if (configStatusMode === 'archived') {
activeBtn.className = 'px-4 py-2 text-sm font-medium bg-white text-gray-700 hover:bg-gray-50';
archivedBtn.className = 'px-4 py-2 text-sm font-medium bg-blue-600 text-white border-l border-gray-200';
actionButtons.classList.add('hidden');
} else {
activeBtn.className = 'px-4 py-2 text-sm font-medium bg-blue-600 text-white';
archivedBtn.className = 'px-4 py-2 text-sm font-medium bg-white text-gray-700 hover:bg-gray-50 border-l border-gray-200';
actionButtons.classList.remove('hidden');
}
}
function renderConfigs(configs) {
const emptyText = configStatusMode === 'archived' ? 'Архив пуст' : 'Нет квот в проекте';
if (configs.length === 0) {
document.getElementById('configs-list').innerHTML =
'<div class="bg-white rounded-lg shadow p-8 text-center text-gray-500">' + emptyText + '</div>';
return;
}
let totalSum = 0;
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">Line</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-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-2 py-3 text-center text-xs font-medium text-gray-500 uppercase w-12"></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" id="project-configs-tbody">';
configs.forEach((c, idx) => {
const total = c.total_price || 0;
const serverCount = c.server_count || 1;
const author = c.owner_username || (c.user && c.user.username) || '—';
const unitPrice = serverCount > 0 ? (total / serverCount) : 0;
const lineValue = (idx + 1) * 10;
const serverModel = (c.server_model || '').trim() || '—';
totalSum += total;
const draggable = configStatusMode === 'active' ? 'true' : 'false';
html += '<tr class="hover:bg-gray-50" draggable="' + draggable + '" data-config-uuid="' + c.uuid + '" ondragstart="onConfigDragStart(event)" ondragover="onConfigDragOver(event)" ondragleave="onConfigDragLeave(event)" ondrop="onConfigDrop(event)" ondragend="onConfigDragEnd(event)">';
if (configStatusMode === 'active') {
html += '<td class="px-4 py-3 text-sm text-gray-500">';
html += '<span class="inline-flex items-center gap-2"><span class="drag-handle text-gray-400 hover:text-gray-700 cursor-grab active:cursor-grabbing select-none" title="Перетащить для изменения порядка" aria-label="Перетащить">';
html += '<svg class="w-4 h-4 pointer-events-none" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 6h.01M8 12h.01M8 18h.01M16 6h.01M16 12h.01M16 18h.01"></path></svg>';
html += '</span><span>' + lineValue + '</span></span></td>';
} else {
html += '<td class="px-4 py-3 text-sm text-gray-500">' + lineValue + '</td>';
}
html += '<td class="px-4 py-3 text-sm text-gray-500">' + escapeHtml(serverModel) + '</td>';
if (configStatusMode === 'archived') {
html += '<td class="px-4 py-3 text-sm font-medium text-gray-700">' + escapeHtml(c.name) + '</td>';
} else {
html += '<td class="px-4 py-3 text-sm font-medium"><a href="/configurator?uuid=' + c.uuid + '" class="text-blue-600 hover:text-blue-800 hover:underline">' + escapeHtml(c.name) + '</a></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">' + formatMoneyNoDecimals(unitPrice) + '</td>';
if (configStatusMode === 'archived') {
html += '<td class="px-4 py-3 text-sm text-gray-500">' + serverCount + '</td>';
} else {
html += '<td class="px-4 py-3 text-sm text-gray-500"><input type="number" min="1" value="' + serverCount + '" class="w-16 px-1 py-0.5 border rounded text-center text-sm" data-uuid="' + c.uuid + '" data-prev="' + serverCount + '" onchange="updateConfigServerCount(this)"></td>';
}
html += '<td class="px-4 py-3 text-sm text-right" data-total-uuid="' + c.uuid + '">' + formatMoneyNoDecimals(total) + '</td>';
const versionNo = c.current_version_no || 1;
html += '<td class="px-2 py-3 text-sm text-center text-gray-500 w-12">v' + versionNo + '</td>';
html += '<td class="px-4 py-3 text-sm text-right whitespace-nowrap"><div class="inline-flex items-center justify-end gap-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="openConfigActionModal(\'' + c.uuid + '\', \'' + escapeHtml(c.name).replace(/'/g, "\\'") + '\', \'' + (c.project_uuid || projectUUID) + '\')" 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="M4 7h16M4 12h16M4 17h16"></path></svg></button>';
html += '<button onclick="deleteConfig(\'' + c.uuid + '\')" class="text-red-600 hover:text-red-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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path></svg></button>';
}
html += '</div></td></tr>';
});
html += '</tbody>';
html += '<tfoot class="bg-gray-50 border-t">';
html += '<tr>';
html += '<td class="px-4 py-3 text-sm font-medium text-gray-700" colspan="5">Итого по проекту</td>';
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" data-footer-total="1">' + formatMoneyNoDecimals(totalSum) + '</td>';
html += '<td class="px-4 py-3"></td>';
html += '<td class="px-4 py-3"></td>';
html += '<td class="px-4 py-3"></td>';
html += '</tr>';
html += '</tfoot>';
html += '</table></div>';
document.getElementById('configs-list').innerHTML = html;
}
async function loadProject() {
const resp = await fetch('/api/projects/' + projectUUID);
if (!resp.ok) {
document.getElementById('configs-list').innerHTML = '<div class="bg-white rounded-lg shadow p-8 text-center text-red-600">Проект не найден</div>';
return false;
}
project = await resp.json();
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) {
trackerLink.classList.add('hidden');
return true;
}
const trackerURL = resolveProjectTrackerURL(project);
if (trackerURL) {
trackerLink.href = trackerURL;
trackerLink.classList.remove('hidden');
} else {
trackerLink.classList.add('hidden');
}
}
return true;
}
async function loadConfigs() {
try {
const resp = await fetch('/api/projects/' + projectUUID + '/configs?status=' + configStatusMode);
if (!resp.ok) {
document.getElementById('configs-list').innerHTML =
'<div class="bg-white rounded-lg shadow p-8 text-center text-red-600">Ошибка загрузки</div>';
return;
}
const data = await resp.json();
allConfigs = (data.configurations || []);
renderConfigs(allConfigs);
} catch (e) {
document.getElementById('configs-list').innerHTML =
'<div class="bg-white rounded-lg shadow p-8 text-center text-red-600">Ошибка загрузки</div>';
}
}
function openCreateModal() {
document.getElementById('create-name').value = '';
document.getElementById('create-modal').classList.remove('hidden');
document.getElementById('create-modal').classList.add('flex');
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');
}
async function createConfig() {
const name = document.getElementById('create-name').value.trim();
if (!name) {
alert('Введите название');
return;
}
const resp = await fetch('/api/projects/' + projectUUID + '/configs', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({name: name, items: [], notes: '', server_count: 1})
});
if (!resp.ok) {
alert('Не удалось создать квоту');
return;
}
closeCreateModal();
loadConfigs();
}
async function deleteConfig(uuid) {
if (!confirm('Переместить квоту в архив?')) return;
await fetch('/api/configs/' + uuid, {method: 'DELETE'});
loadConfigs();
}
async function reactivateConfig(uuid) {
const resp = await fetch('/api/configs/' + uuid + '/reactivate', {method: 'POST'});
if (!resp.ok) {
alert('Не удалось восстановить квоту');
return;
}
const moved = await fetch('/api/configs/' + uuid + '/project', {
method: 'PATCH',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({project_uuid: projectUUID})
});
if (!moved.ok) {
alert('Квота восстановлена, но не удалось вернуть в проект');
}
loadConfigs();
}
function projectCodeEntries() {
const byCode = new Map();
projectsCatalog.forEach(p => {
const code = (p.code || '').trim();
if (!code || byCode.has(code)) return;
byCode.set(code, {
code: code,
name: (p.name || '').trim()
});
});
return Array.from(byCode.values()).sort((a, b) => a.code.localeCompare(b.code, 'ru'));
}
function formatProjectAutocompleteValue(entry) {
if (!entry) return '';
return entry.name ? (entry.code + ' - ' + entry.name) : entry.code;
}
function resolveProjectCodeFromInput(rawInput) {
const input = (rawInput || '').trim();
if (!input) return '';
const entries = projectCodeEntries();
const exactCode = entries.find(e => e.code.toLowerCase() === input.toLowerCase());
if (exactCode) return exactCode.code;
const exactDisplayMatches = entries.filter(e => formatProjectAutocompleteValue(e).toLowerCase() === input.toLowerCase());
if (exactDisplayMatches.length === 1) return exactDisplayMatches[0].code;
const byUniqueName = entries.filter(e => (e.name || '').toLowerCase() === input.toLowerCase());
if (byUniqueName.length === 1) return byUniqueName[0].code;
if (input.includes(' - ')) {
const codeCandidate = input.split(' - ')[0].trim();
const byCandidate = entries.find(e => e.code.toLowerCase() === codeCandidate.toLowerCase());
if (byCandidate) return byCandidate.code;
}
return '';
}
function populateProjectAutocomplete() {
const options = document.getElementById('config-action-project-options');
options.innerHTML = '';
projectCodeEntries().forEach(entry => {
const opt = document.createElement('option');
opt.value = formatProjectAutocompleteValue(entry);
opt.label = entry.code;
options.appendChild(opt);
});
}
function variantsForProjectCode(projectCode) {
const code = (projectCode || '').trim();
if (!code) return [];
return projectsCatalog
.filter(p => (p.code || '').trim() === code)
.map(p => ({uuid: p.uuid, variant: normalizeVariantLabel(p.variant || '')}))
.sort((a, b) => a.variant.localeCompare(b.variant, 'ru'));
}
function populateVariantAutocomplete(projectCode, selectedVariantLabel) {
const options = document.getElementById('config-action-variant-options');
const input = document.getElementById('config-action-variant-input');
const variants = variantsForProjectCode(projectCode);
options.innerHTML = '';
variants.forEach(v => {
const opt = document.createElement('option');
opt.value = v.variant;
options.appendChild(opt);
});
if (selectedVariantLabel) {
input.value = selectedVariantLabel;
} else if (variants.length === 1) {
input.value = variants[0].variant;
} else {
input.value = '';
}
}
function resolveTargetProjectUUIDFromInputs() {
const projectCode = resolveProjectCodeFromInput(document.getElementById('config-action-project-input').value);
if (!projectCode) {
return {error: 'Выберите проект из подсказок'};
}
const variantLabel = normalizeVariantLabel(document.getElementById('config-action-variant-input').value || 'main');
const target = projectsCatalog.find(p =>
(p.code || '').trim() === projectCode &&
normalizeVariantLabel(p.variant || '') === variantLabel
);
if (!target) {
return {error: 'Выберите вариант из подсказок'};
}
return {uuid: target.uuid};
}
function syncActionModalMode() {
const copyCheckbox = document.getElementById('config-action-copy');
if (copyCheckbox.checked) {
// no-op: copy always uses latest revision
} else {
// no-op: copy always uses latest revision
}
}
async function openConfigActionModal(uuid, currentName, currentProjectUUID) {
document.getElementById('config-action-uuid').value = uuid;
document.getElementById('config-action-current-name').value = currentName;
document.getElementById('config-action-current-project').value = currentProjectUUID || projectUUID;
document.getElementById('config-action-name').value = currentName;
document.getElementById('config-action-copy').checked = false;
populateProjectAutocomplete();
const currentProject = projectsCatalog.find(p => p.uuid === (currentProjectUUID || projectUUID));
if (currentProject) {
const entry = {
code: (currentProject.code || '').trim(),
name: (currentProject.name || '').trim()
};
document.getElementById('config-action-project-input').value = formatProjectAutocompleteValue(entry);
populateVariantAutocomplete(entry.code, normalizeVariantLabel(currentProject.variant || ''));
} else {
document.getElementById('config-action-project-input').value = '';
populateVariantAutocomplete('', '');
}
syncActionModalMode();
document.getElementById('config-action-modal').classList.remove('hidden');
document.getElementById('config-action-modal').classList.add('flex');
const nameInput = document.getElementById('config-action-name');
nameInput.focus();
nameInput.select();
}
function closeConfigActionModal() {
document.getElementById('config-action-modal').classList.add('hidden');
document.getElementById('config-action-modal').classList.remove('flex');
}
async function saveConfigAction() {
const notify = (message, type) => {
if (typeof showToast === 'function') {
showToast(message, type || 'success');
} else {
alert(message);
}
};
const uuid = document.getElementById('config-action-uuid').value;
const currentName = document.getElementById('config-action-current-name').value;
const currentProjectUUID = document.getElementById('config-action-current-project').value || projectUUID;
const name = document.getElementById('config-action-name').value.trim();
const copy = document.getElementById('config-action-copy').checked;
const targetProject = resolveTargetProjectUUIDFromInputs();
if (targetProject.error) {
notify(targetProject.error, 'error');
return;
}
const targetProjectUUID = targetProject.uuid || currentProjectUUID;
if (!name) {
notify('Введите название', 'error');
return;
}
if (copy) {
const cloneResp = await fetch('/api/projects/' + targetProjectUUID + '/configs/' + uuid + '/clone', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({name: name})
});
if (!cloneResp.ok) {
notify('Не удалось скопировать квоту', 'error');
return;
}
closeConfigActionModal();
await loadConfigs();
notify('Копия создана', 'success');
if (targetProjectUUID && targetProjectUUID !== projectUUID) {
window.location.href = '/projects/' + targetProjectUUID;
return;
}
return;
}
let changed = false;
if (name !== currentName) {
const renameResp = await fetch('/api/configs/' + uuid + '/rename', {
method: 'PATCH',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({name: name})
});
if (!renameResp.ok) {
notify('Не удалось переименовать квоту', 'error');
return;
}
changed = true;
}
if (targetProjectUUID !== currentProjectUUID) {
const moveResp = await fetch('/api/configs/' + uuid + '/project', {
method: 'PATCH',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({project_uuid: targetProjectUUID})
});
if (!moveResp.ok) {
notify('Не удалось перенести квоту', 'error');
return;
}
changed = true;
}
if (!changed) {
closeConfigActionModal();
return;
}
closeConfigActionModal();
await loadConfigs();
notify('Изменения сохранены', 'success');
if (targetProjectUUID && targetProjectUUID !== projectUUID) {
window.location.href = '/projects/' + targetProjectUUID;
}
}
function openImportModal() {
const activeOther = allConfigs.length ? null : null; // no-op placeholder
void activeOther;
document.getElementById('import-config-input').value = '';
document.getElementById('import-config-options').innerHTML = '';
loadImportOptions();
document.getElementById('import-modal').classList.remove('hidden');
document.getElementById('import-modal').classList.add('flex');
}
function closeImportModal() {
document.getElementById('import-modal').classList.add('hidden');
document.getElementById('import-modal').classList.remove('flex');
}
function openProjectSettingsModal() {
if (!project) return;
if (project.is_system) {
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');
document.getElementById('project-settings-modal').classList.add('flex');
}
function closeProjectSettingsModal() {
document.getElementById('project-settings-modal').classList.add('hidden');
document.getElementById('project-settings-modal').classList.remove('flex');
}
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 (!code) {
alert('Введите код проекта');
return;
}
const resp = await fetch('/api/projects/' + projectUUID, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({code: code, variant: variant, name: name, tracker_url: trackerURL})
});
if (!resp.ok) {
if (resp.status === 409) {
alert('Проект с таким кодом и вариантом уже существует');
return;
}
alert('Не удалось сохранить параметры проекта');
return;
}
project = await resp.json();
document.getElementById('project-code').textContent = project.code || '—';
await loadVariantsForCode(project.code || '');
renderVariantSelect();
const trackerLink = document.getElementById('tracker-link');
if (trackerLink) {
const trackerURLResolved = resolveProjectTrackerURL(project);
if (trackerURLResolved) {
trackerLink.href = trackerURLResolved;
trackerLink.classList.remove('hidden');
} else {
trackerLink.classList.add('hidden');
}
}
closeProjectSettingsModal();
}
async function loadImportOptions() {
const resp = await fetch('/api/configs?page=1&per_page=500&status=active');
if (!resp.ok) return;
const data = await resp.json();
const options = document.getElementById('import-config-options');
options.innerHTML = '';
(data.configurations || [])
.filter(c => c.project_uuid !== projectUUID)
.forEach(c => {
const opt = document.createElement('option');
opt.value = c.name;
options.appendChild(opt);
});
}
async function importConfigToProject() {
const query = document.getElementById('import-config-input').value.trim();
if (!query) {
alert('Выберите квоту');
return;
}
const resp = await fetch('/api/configs?page=1&per_page=500&status=active');
if (!resp.ok) {
alert('Не удалось загрузить список квот');
return;
}
const data = await resp.json();
const sourceConfigs = (data.configurations || []).filter(c => c.project_uuid !== projectUUID);
let targets = [];
if (query.includes('*')) {
targets = sourceConfigs.filter(c => wildcardMatch(c.name || '', query));
} else {
const found = sourceConfigs.find(c => (c.name || '').toLowerCase() === query.toLowerCase());
if (found) {
targets = [found];
}
}
if (!targets.length) {
alert('Подходящие квоты не найдены');
return;
}
let moved = 0;
let failed = 0;
for (const cfg of targets) {
const move = await fetch('/api/configs/' + cfg.uuid + '/project', {
method: 'PATCH',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({project_uuid: projectUUID})
});
if (move.ok) {
moved++;
} else {
failed++;
}
}
if (!moved) {
alert('Не удалось импортировать квоты');
return;
}
closeImportModal();
await loadConfigs();
if (targets.length > 1 || failed > 0) {
alert('Импорт завершен: перенесено ' + moved + ', ошибок ' + failed);
}
}
function wildcardMatch(value, pattern) {
const escaped = pattern
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
.replace(/\*/g, '.*');
const regex = new RegExp('^' + escaped + '$', 'i');
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');
}
}
document.getElementById('create-modal').addEventListener('click', function(e) { if (e.target === this) closeCreateModal(); });
document.getElementById('new-variant-modal').addEventListener('click', function(e) { if (e.target === this) closeNewVariantModal(); });
document.getElementById('config-action-modal').addEventListener('click', function(e) { if (e.target === this) closeConfigActionModal(); });
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('config-action-project-input').addEventListener('input', function(e) {
const code = resolveProjectCodeFromInput(e.target.value);
populateVariantAutocomplete(code, '');
});
document.getElementById('config-action-copy').addEventListener('change', function(e) {
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 + ' (копия)';
}
syncActionModalMode();
});
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
closeCreateModal();
closeConfigActionModal();
closeImportModal();
closeProjectSettingsModal();
}
});
function onConfigDragStart(event) {
if (configStatusMode !== 'active' || isReorderingConfigs) {
event.preventDefault();
return;
}
const row = event.target.closest('tr[data-config-uuid]');
if (!row) {
event.preventDefault();
return;
}
dragConfigUUID = row.dataset.configUuid || '';
if (!dragConfigUUID) {
event.preventDefault();
return;
}
row.classList.add('opacity-50');
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('text/plain', dragConfigUUID);
}
function onConfigDragOver(event) {
if (!dragConfigUUID || configStatusMode !== 'active') return;
event.preventDefault();
const row = event.target.closest('tr[data-config-uuid]');
if (!row || row.dataset.configUuid === dragConfigUUID) return;
row.classList.add('ring-2', 'ring-blue-300');
}
function onConfigDragLeave(event) {
const row = event.target.closest('tr[data-config-uuid]');
if (!row) return;
row.classList.remove('ring-2', 'ring-blue-300');
}
async function onConfigDrop(event) {
if (!dragConfigUUID || configStatusMode !== 'active' || isReorderingConfigs) return;
event.preventDefault();
const targetRow = event.target.closest('tr[data-config-uuid]');
if (!targetRow) return;
targetRow.classList.remove('ring-2', 'ring-blue-300');
const targetUUID = targetRow.dataset.configUuid || '';
if (!targetUUID || targetUUID === dragConfigUUID) return;
const previous = allConfigs.slice();
const fromIndex = allConfigs.findIndex(c => c.uuid === dragConfigUUID);
const toIndex = allConfigs.findIndex(c => c.uuid === targetUUID);
if (fromIndex < 0 || toIndex < 0) return;
const [moved] = allConfigs.splice(fromIndex, 1);
allConfigs.splice(toIndex, 0, moved);
renderConfigs(allConfigs);
await saveConfigReorder(previous);
}
function onConfigDragEnd() {
document.querySelectorAll('tr[data-config-uuid]').forEach(row => {
row.classList.remove('ring-2', 'ring-blue-300', 'opacity-50');
});
dragConfigUUID = '';
}
async function saveConfigReorder(previousConfigs) {
if (isReorderingConfigs) return;
isReorderingConfigs = true;
const orderedUUIDs = allConfigs.map(c => c.uuid);
try {
const resp = await fetch('/api/projects/' + projectUUID + '/configs/reorder', {
method: 'PATCH',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ordered_uuids: orderedUUIDs}),
});
if (!resp.ok) {
const data = await resp.json().catch(() => ({}));
throw new Error(data.error || 'Не удалось сохранить порядок');
}
const data = await resp.json();
allConfigs = data.configurations || allConfigs;
renderConfigs(allConfigs);
if (typeof showToast === 'function') {
showToast('Порядок сохранён', 'success');
}
} catch (e) {
allConfigs = previousConfigs.slice();
renderConfigs(allConfigs);
if (typeof showToast === 'function') {
showToast(e.message || 'Не удалось сохранить порядок', 'error');
} else {
alert(e.message || 'Не удалось сохранить порядок');
}
} finally {
isReorderingConfigs = false;
dragConfigUUID = '';
}
}
async function updateConfigServerCount(input) {
const uuid = input.dataset.uuid;
const prevValue = parseInt(input.dataset.prev) || 1;
const newValue = parseInt(input.value);
if (!newValue || newValue < 1) {
input.value = prevValue;
return;
}
try {
const resp = await fetch('/api/configs/' + uuid + '/server-count', {
method: 'PATCH',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({server_count: newValue})
});
if (!resp.ok) {
input.value = prevValue;
showToast('Не удалось обновить количество', 'error');
return;
}
const updated = await resp.json();
input.dataset.prev = newValue;
// Update row total price
const totalCell = document.querySelector('[data-total-uuid="' + uuid + '"]');
if (totalCell && updated.total_price != null) {
totalCell.textContent = formatMoneyNoDecimals(updated.total_price);
}
// Update the config in allConfigs and recalculate footer total
for (let i = 0; i < allConfigs.length; i++) {
if (allConfigs[i].uuid === uuid) {
allConfigs[i].total_price = updated.total_price;
allConfigs[i].server_count = newValue;
break;
}
}
updateFooterTotal();
} catch (e) {
input.value = prevValue;
showToast('Ошибка сети', 'error');
}
}
function updateFooterTotal() {
let totalSum = 0;
allConfigs.forEach(c => { totalSum += (c.total_price || 0); });
const footer = document.querySelector('tfoot td[data-footer-total]');
if (footer) {
footer.textContent = formatMoneyNoDecimals(totalSum);
}
}
function exportProject() {
window.location.href = '/api/projects/' + projectUUID + '/export';
}
document.addEventListener('DOMContentLoaded', async function() {
applyStatusModeUI();
const ok = await loadProject();
if (!ok) return;
updateDeleteVariantButton();
await loadConfigs();
});
</script>
{{end}}
{{template "base" .}}