feat: add projects flow and consolidate default project handling
This commit is contained in:
@@ -22,6 +22,11 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="max-w-md">
|
||||
<input id="configs-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>
|
||||
|
||||
<div id="pricelist-badge" class="mt-4 text-sm text-gray-600 hidden">
|
||||
<span class="bg-blue-100 text-blue-800 px-2 py-1 rounded-full">
|
||||
<svg class="w-4 h-4 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -56,6 +61,13 @@
|
||||
<input type="text" id="opportunity-number" placeholder="Например: OPP-2024-001"
|
||||
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>
|
||||
<select id="create-project-select"
|
||||
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
<option value="">Без проекта</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end space-x-3 mt-6">
|
||||
@@ -119,12 +131,65 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal for moving configuration to another project -->
|
||||
<div id="move-project-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 class="text-sm text-gray-600">
|
||||
Квота: <span id="move-project-config-name" class="font-medium text-gray-900"></span>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Проект</label>
|
||||
<input id="move-project-input"
|
||||
list="move-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="move-project-options"></datalist>
|
||||
<div class="mt-2 flex justify-between items-center gap-3">
|
||||
<button type="button" onclick="clearMoveProjectInput()" class="text-sm text-gray-600 hover:text-gray-800">
|
||||
Без проекта
|
||||
</button>
|
||||
</div>
|
||||
<input type="hidden" id="move-project-uuid">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end space-x-3 mt-6">
|
||||
<button onclick="closeMoveProjectModal()" class="px-4 py-2 text-gray-600 hover:text-gray-800">
|
||||
Отмена
|
||||
</button>
|
||||
<button onclick="confirmMoveProject()" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">
|
||||
Перенести
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal for creating project during move -->
|
||||
<div id="create-project-on-move-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-3">Проект не найден</h2>
|
||||
<p class="text-sm text-gray-600 mb-4">Проект "<span id="create-project-on-move-name" class="font-medium text-gray-900"></span>" не найден. Создать и привязать квоту?</p>
|
||||
<div class="flex justify-end space-x-3">
|
||||
<button onclick="closeCreateProjectOnMoveModal()" class="px-4 py-2 text-gray-600 hover:text-gray-800">Отмена</button>
|
||||
<button onclick="confirmCreateProjectOnMove()" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">Создать и привязать</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Pagination state
|
||||
let currentPage = 1;
|
||||
let totalPages = 1;
|
||||
let perPage = 20;
|
||||
let configStatusMode = 'active';
|
||||
let configsSearch = '';
|
||||
let projectsCache = [];
|
||||
let projectNameByUUID = {};
|
||||
let pendingMoveConfigUUID = '';
|
||||
let pendingMoveProjectName = '';
|
||||
|
||||
function renderConfigs(configs) {
|
||||
const emptyText = configStatusMode === 'archived'
|
||||
@@ -139,6 +204,7 @@ function renderConfigs(configs) {
|
||||
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">Автор</th>';
|
||||
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Цена (за 1 шт)</th>';
|
||||
@@ -152,6 +218,9 @@ function renderConfigs(configs) {
|
||||
const total = c.total_price ? '$' + c.total_price.toLocaleString('en-US', {minimumFractionDigits: 2}) : '—';
|
||||
const serverCount = c.server_count ? c.server_count : 1;
|
||||
const author = c.owner_username || (c.user && c.user.username) || '—';
|
||||
const projectName = c.project_uuid && projectNameByUUID[c.project_uuid]
|
||||
? projectNameByUUID[c.project_uuid]
|
||||
: 'Без проекта';
|
||||
|
||||
// Calculate price per unit (total / server count)
|
||||
let pricePerUnit = '—';
|
||||
@@ -162,6 +231,19 @@ function renderConfigs(configs) {
|
||||
|
||||
html += '<tr class="hover:bg-gray-50">';
|
||||
html += '<td class="px-4 py-3 text-sm text-gray-500">' + date + '</td>';
|
||||
if (configStatusMode === 'archived') {
|
||||
if (c.project_uuid) {
|
||||
html += '<td class="px-4 py-3 text-sm"><a href="/projects/' + c.project_uuid + '" class="text-blue-600 hover:text-blue-800 hover:underline">' + escapeHtml(projectName) + '</a></td>';
|
||||
} else {
|
||||
html += '<td class="px-4 py-3 text-sm text-gray-500">' + escapeHtml(projectName) + '</td>';
|
||||
}
|
||||
} else {
|
||||
if (c.project_uuid) {
|
||||
html += '<td class="px-4 py-3 text-sm"><a href="/projects/' + c.project_uuid + '" class="text-blue-600 hover:text-blue-800 hover:underline">' + escapeHtml(projectName) + '</a></td>';
|
||||
} else {
|
||||
html += '<td class="px-4 py-3 text-sm text-gray-700">' + escapeHtml(projectName) + '</td>';
|
||||
}
|
||||
}
|
||||
if (configStatusMode === 'archived') {
|
||||
html += '<td class="px-4 py-3 text-sm font-medium text-gray-700">' + escapeHtml(c.name) + '</td>';
|
||||
} else {
|
||||
@@ -179,6 +261,11 @@ function renderConfigs(configs) {
|
||||
html += '</svg>';
|
||||
html += '</button>';
|
||||
} else {
|
||||
html += '<button onclick="openMoveProjectModal(\'' + c.uuid + '\', \'' + escapeHtml(c.name).replace(/'/g, "\\'") + '\', \'' + (c.project_uuid || '') + '\')" 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">';
|
||||
html += '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16V4m0 0l-3 3m3-3l3 3m7 1v12m0 0l-3-3m3 3l3-3"></path>';
|
||||
html += '</svg>';
|
||||
html += '</button>';
|
||||
html += '<button onclick="openCloneModal(\'' + c.uuid + '\', \'' + escapeHtml(c.name).replace(/'/g, "\\'") + '\')" class="text-green-600 hover:text-green-800" title="Копировать">';
|
||||
html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">';
|
||||
html += '<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>';
|
||||
@@ -338,6 +425,8 @@ async function createConfig() {
|
||||
return;
|
||||
}
|
||||
|
||||
const projectUUID = document.getElementById('create-project-select').value;
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/configs', {
|
||||
method: 'POST',
|
||||
@@ -348,7 +437,8 @@ async function createConfig() {
|
||||
name: name,
|
||||
items: [],
|
||||
notes: '',
|
||||
server_count: 1
|
||||
server_count: 1,
|
||||
project_uuid: projectUUID || null
|
||||
})
|
||||
});
|
||||
|
||||
@@ -365,6 +455,129 @@ async function createConfig() {
|
||||
}
|
||||
}
|
||||
|
||||
function openMoveProjectModal(uuid, configName, currentProjectUUID) {
|
||||
document.getElementById('move-project-uuid').value = uuid;
|
||||
document.getElementById('move-project-config-name').textContent = configName;
|
||||
|
||||
const input = document.getElementById('move-project-input');
|
||||
const options = document.getElementById('move-project-options');
|
||||
options.innerHTML = '';
|
||||
projectsCache.forEach(project => {
|
||||
if (!project.is_active) return;
|
||||
const option = document.createElement('option');
|
||||
option.value = project.name;
|
||||
options.appendChild(option);
|
||||
});
|
||||
|
||||
if (currentProjectUUID && projectNameByUUID[currentProjectUUID]) {
|
||||
input.value = projectNameByUUID[currentProjectUUID];
|
||||
} else {
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
document.getElementById('move-project-modal').classList.remove('hidden');
|
||||
document.getElementById('move-project-modal').classList.add('flex');
|
||||
}
|
||||
|
||||
function closeMoveProjectModal() {
|
||||
document.getElementById('move-project-modal').classList.add('hidden');
|
||||
document.getElementById('move-project-modal').classList.remove('flex');
|
||||
}
|
||||
|
||||
async function confirmMoveProject() {
|
||||
const uuid = document.getElementById('move-project-uuid').value;
|
||||
const projectName = document.getElementById('move-project-input').value.trim();
|
||||
|
||||
if (!uuid) return;
|
||||
let projectUUID = '';
|
||||
|
||||
if (projectName) {
|
||||
const existingProject = projectsCache.find(p => p.is_active && p.name.toLowerCase() === projectName.toLowerCase());
|
||||
if (existingProject) {
|
||||
projectUUID = existingProject.uuid;
|
||||
} else {
|
||||
pendingMoveConfigUUID = uuid;
|
||||
pendingMoveProjectName = projectName;
|
||||
openCreateProjectOnMoveModal(projectName);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await moveConfigToProject(uuid, projectUUID);
|
||||
}
|
||||
|
||||
function clearMoveProjectInput() {
|
||||
document.getElementById('move-project-input').value = '';
|
||||
}
|
||||
|
||||
function openCreateProjectOnMoveModal(projectName) {
|
||||
document.getElementById('create-project-on-move-name').textContent = projectName;
|
||||
document.getElementById('create-project-on-move-modal').classList.remove('hidden');
|
||||
document.getElementById('create-project-on-move-modal').classList.add('flex');
|
||||
}
|
||||
|
||||
function closeCreateProjectOnMoveModal() {
|
||||
document.getElementById('create-project-on-move-modal').classList.add('hidden');
|
||||
document.getElementById('create-project-on-move-modal').classList.remove('flex');
|
||||
pendingMoveConfigUUID = '';
|
||||
pendingMoveProjectName = '';
|
||||
}
|
||||
|
||||
async function confirmCreateProjectOnMove() {
|
||||
const configUUID = pendingMoveConfigUUID;
|
||||
const projectName = pendingMoveProjectName;
|
||||
if (!configUUID || !projectName) {
|
||||
closeCreateProjectOnMoveModal();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const createResp = await fetch('/api/projects', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({ name: projectName })
|
||||
});
|
||||
if (!createResp.ok) {
|
||||
const err = await createResp.json();
|
||||
alert('Не удалось создать проект: ' + (err.error || 'ошибка'));
|
||||
return;
|
||||
}
|
||||
|
||||
const newProject = await createResp.json();
|
||||
const moved = await moveConfigToProject(configUUID, newProject.uuid);
|
||||
if (moved) {
|
||||
closeCreateProjectOnMoveModal();
|
||||
closeMoveProjectModal();
|
||||
}
|
||||
} catch (e) {
|
||||
alert('Ошибка создания проекта');
|
||||
}
|
||||
}
|
||||
|
||||
async function moveConfigToProject(uuid, projectUUID) {
|
||||
try {
|
||||
const resp = await fetch('/api/configs/' + uuid + '/project', {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ project_uuid: projectUUID })
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json();
|
||||
alert('Не удалось перенести квоту: ' + (err.error || 'ошибка'));
|
||||
return false;
|
||||
}
|
||||
closeMoveProjectModal();
|
||||
await loadProjectsForConfigUI();
|
||||
await loadConfigs();
|
||||
return true;
|
||||
} catch (e) {
|
||||
alert('Ошибка переноса квоты');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Close modal on outside click
|
||||
document.getElementById('create-modal').addEventListener('click', function(e) {
|
||||
if (e.target === this) {
|
||||
@@ -384,12 +597,26 @@ document.getElementById('clone-modal').addEventListener('click', function(e) {
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('move-project-modal').addEventListener('click', function(e) {
|
||||
if (e.target === this) {
|
||||
closeMoveProjectModal();
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('create-project-on-move-modal').addEventListener('click', function(e) {
|
||||
if (e.target === this) {
|
||||
closeCreateProjectOnMoveModal();
|
||||
}
|
||||
});
|
||||
|
||||
// Close modal on Escape key
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') {
|
||||
closeCreateModal();
|
||||
closeRenameModal();
|
||||
closeCloneModal();
|
||||
closeMoveProjectModal();
|
||||
closeCreateProjectOnMoveModal();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -461,7 +688,7 @@ function applyStatusModeUI() {
|
||||
// Load configs with pagination
|
||||
async function loadConfigs() {
|
||||
try {
|
||||
const resp = await fetch('/api/configs?page=' + currentPage + '&per_page=' + perPage + '&status=' + configStatusMode);
|
||||
const resp = await fetch('/api/configs?page=' + currentPage + '&per_page=' + perPage + '&status=' + configStatusMode + '&search=' + encodeURIComponent(configsSearch));
|
||||
|
||||
if (!resp.ok) {
|
||||
document.getElementById('configs-list').innerHTML =
|
||||
@@ -512,12 +739,44 @@ async function importConfigsFromServer() {
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
applyStatusModeUI();
|
||||
loadConfigs();
|
||||
loadProjectsForConfigUI().then(loadConfigs);
|
||||
|
||||
// Load latest pricelist version for badge
|
||||
loadLatestPricelistVersion();
|
||||
});
|
||||
|
||||
document.getElementById('configs-search').addEventListener('input', function(e) {
|
||||
configsSearch = (e.target.value || '').trim();
|
||||
currentPage = 1;
|
||||
loadConfigs();
|
||||
});
|
||||
|
||||
async function loadProjectsForConfigUI() {
|
||||
projectsCache = [];
|
||||
projectNameByUUID = {};
|
||||
try {
|
||||
const resp = await fetch('/api/projects?status=all');
|
||||
if (!resp.ok) return;
|
||||
const data = await resp.json();
|
||||
projectsCache = (data.projects || []);
|
||||
|
||||
const select = document.getElementById('create-project-select');
|
||||
if (select) {
|
||||
select.innerHTML = '<option value="">Без проекта</option>';
|
||||
projectsCache.forEach(project => {
|
||||
projectNameByUUID[project.uuid] = project.name;
|
||||
if (!project.is_active) return;
|
||||
const option = document.createElement('option');
|
||||
option.value = project.uuid;
|
||||
option.textContent = project.name;
|
||||
select.appendChild(option);
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
// keep default behavior without project selection data
|
||||
}
|
||||
}
|
||||
|
||||
async function loadLatestPricelistVersion() {
|
||||
try {
|
||||
const resp = await fetch('/api/pricelists/latest');
|
||||
|
||||
Reference in New Issue
Block a user