1062 lines
47 KiB
HTML
1062 lines
47 KiB
HTML
{{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 projectVariants = [];
|
||
let projectsCatalog = [];
|
||
let variantMenuInitialized = false;
|
||
|
||
function escapeHtml(text) {
|
||
const div = document.createElement('div');
|
||
div.textContent = text || '';
|
||
return div.innerHTML;
|
||
}
|
||
|
||
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">Дата</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-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">';
|
||
|
||
configs.forEach(c => {
|
||
const date = new Date(c.created_at).toLocaleDateString('ru-RU');
|
||
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;
|
||
totalSum += total;
|
||
|
||
html += '<tr class="hover:bg-gray-50">';
|
||
html += '<td class="px-4 py-3 text-sm text-gray-500">' + date + '</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">$' + unitPrice.toLocaleString('en-US', {minimumFractionDigits: 2}) + '</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 + '">$' + 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="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 += '</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="4">Итого по проекту</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">$' + 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>';
|
||
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();
|
||
}
|
||
});
|
||
|
||
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 = '$' + updated.total_price.toLocaleString('en-US', {minimumFractionDigits: 2});
|
||
}
|
||
// 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 = '$' + totalSum.toLocaleString('en-US', {minimumFractionDigits: 2});
|
||
}
|
||
}
|
||
|
||
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" .}}
|