Add vendor workspace import and pricing export workflow

This commit is contained in:
Mikhail Chusavitin
2026-03-07 21:03:40 +03:00
parent 08ecfd0826
commit 7c3752f110
30 changed files with 3042 additions and 482 deletions

View File

@@ -20,7 +20,7 @@
</div>
<div class="max-w-md">
<input id="configs-search" type="text" placeholder="Поиск квоты по названию"
<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>
@@ -141,7 +141,7 @@
<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>
Конфигурация: <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>
@@ -174,7 +174,7 @@
<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-code" class="font-medium text-gray-900"></span>" не найден. <span id="create-project-on-move-description">Создать и привязать квоту?</span></p>
<p class="text-sm text-gray-600 mb-4">Проект с кодом "<span id="create-project-on-move-code" class="font-medium text-gray-900"></span>" не найден. <span id="create-project-on-move-description">Создать и привязать конфигурацию?</span></p>
<div class="mb-4">
<label for="create-project-on-move-name" class="block text-sm font-medium text-gray-700 mb-1">Название проекта</label>
<input id="create-project-on-move-name" type="text" placeholder="Например: Инфраструктура для OPS-123"
@@ -601,7 +601,7 @@ function openCreateProjectOnMoveModal(projectName) {
document.getElementById('create-project-on-move-code').textContent = projectName;
document.getElementById('create-project-on-move-name').value = projectName;
document.getElementById('create-project-on-move-variant').value = '';
document.getElementById('create-project-on-move-description').textContent = 'Создать и привязать квоту?';
document.getElementById('create-project-on-move-description').textContent = 'Создать и привязать конфигурацию?';
document.getElementById('create-project-on-move-confirm-btn').textContent = 'Создать и привязать';
document.getElementById('create-project-on-move-modal').classList.remove('hidden');
document.getElementById('create-project-on-move-modal').classList.add('flex');
@@ -719,7 +719,7 @@ async function moveConfigToProject(uuid, projectUUID) {
});
if (!resp.ok) {
const err = await resp.json();
alert('Не удалось перенести квоту: ' + (err.error || 'ошибка'));
alert('Не удалось перенести конфигурацию: ' + (err.error || 'ошибка'));
return false;
}
closeMoveProjectModal();
@@ -727,7 +727,7 @@ async function moveConfigToProject(uuid, projectUUID) {
await loadConfigs();
return true;
} catch (e) {
alert('Ошибка переноса квоты');
alert('Ошибка переноса конфигурации');
return false;
}
}

View File

@@ -755,6 +755,9 @@ document.addEventListener('DOMContentLoaded', async function() {
document.getElementById('server-count').value = serverCount;
document.getElementById('total-server-count').textContent = serverCount;
selectedPricelistIds.estimate = config.pricelist_id || null;
selectedPricelistIds.warehouse = config.warehouse_pricelist_id || null;
selectedPricelistIds.competitor = config.competitor_pricelist_id || null;
disablePriceRefresh = Boolean(config.disable_price_refresh);
onlyInStock = Boolean(config.only_in_stock);
if (config.items && config.items.length > 0) {
@@ -1983,6 +1986,9 @@ function buildSavePayload() {
support_code: supportCode,
article: getCurrentArticle(),
pricelist_id: selectedPricelistIds.estimate,
warehouse_pricelist_id: selectedPricelistIds.warehouse,
competitor_pricelist_id: selectedPricelistIds.competitor,
disable_price_refresh: disablePriceRefresh,
only_in_stock: onlyInStock
};
}

View File

@@ -29,30 +29,6 @@
Нет активного листа сопоставлений. Нажмите «Синхронизировать» для загрузки с сервера.
</div>
<!-- Active book items with search -->
<div id="active-book-section" class="hidden bg-white rounded-lg shadow overflow-hidden">
<div class="px-4 py-3 border-b flex items-center justify-between gap-3">
<span class="font-semibold text-gray-800 whitespace-nowrap">Сопоставления активного листа</span>
<input type="text" id="pn-search" placeholder="Поиск по PN или LOT..."
class="flex-1 max-w-xs px-3 py-1.5 border rounded text-sm focus:ring-2 focus:ring-blue-400 focus:outline-none"
oninput="filterItems(this.value)">
<span id="pn-count" class="text-xs text-gray-400 whitespace-nowrap"></span>
</div>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-gray-50 text-gray-600 sticky top-0">
<tr>
<th class="px-4 py-2 text-left">Partnumber</th>
<th class="px-4 py-2 text-left">LOT</th>
<th class="px-4 py-2 text-center w-24">Primary</th>
<th class="px-4 py-2 text-left">Описание</th>
</tr>
</thead>
<tbody id="active-items-body"></tbody>
</table>
</div>
</div>
<!-- All books list (collapsed by default) -->
<div class="bg-white rounded-lg shadow overflow-hidden">
<!-- Header row — always visible -->
@@ -92,14 +68,51 @@
</div>
</div>
</div>
<!-- Active book items with search -->
<div id="active-book-section" class="hidden bg-white rounded-lg shadow overflow-hidden">
<div class="px-4 py-3 border-b flex items-center justify-between gap-3">
<span class="font-semibold text-gray-800 whitespace-nowrap">Сопоставления активного листа</span>
<input type="text" id="pn-search" placeholder="Поиск по PN или LOT..."
class="flex-1 max-w-xs px-3 py-1.5 border rounded text-sm focus:ring-2 focus:ring-blue-400 focus:outline-none"
oninput="onItemsSearchInput(this.value)">
<span id="pn-count" class="text-xs text-gray-400 whitespace-nowrap"></span>
</div>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-gray-50 text-gray-600 sticky top-0">
<tr>
<th class="px-4 py-2 text-left">Partnumber</th>
<th class="px-4 py-2 text-left">LOT</th>
<th class="px-4 py-2 text-center w-24">Primary</th>
<th class="px-4 py-2 text-left">Описание</th>
</tr>
</thead>
<tbody id="active-items-body"></tbody>
</table>
</div>
<div id="items-pagination" class="hidden px-4 py-3 border-t flex items-center justify-between text-sm text-gray-600">
<span id="items-page-info"></span>
<div class="flex gap-2">
<button id="items-prev" onclick="changeItemsPage(-1)" class="px-3 py-1 rounded border hover:bg-gray-50 disabled:opacity-40 disabled:cursor-not-allowed">← Назад</button>
<button id="items-next" onclick="changeItemsPage(1)" class="px-3 py-1 rounded border hover:bg-gray-50 disabled:opacity-40 disabled:cursor-not-allowed">Вперёд →</button>
</div>
</div>
</div>
</div>
<script>
let allItems = [];
let allBooks = [];
let booksPage = 1;
const BOOKS_PER_PAGE = 10;
const ITEMS_PER_PAGE = 100;
let activeBookServerID = null;
let activeItems = [];
let itemsPage = 1;
let itemsTotal = 0;
let itemsSearch = '';
let _itemsSearchTimer = null;
function toggleBooksSection() {
const body = document.getElementById('books-section-body');
@@ -179,29 +192,46 @@ function changeBooksPage(delta) {
}
async function loadActiveBookItems(book) {
activeBookServerID = book.server_id;
return loadActiveBookItemsPage(1, itemsSearch, book);
}
async function loadActiveBookItemsPage(page = 1, search = '', book = null) {
const targetBook = book || allBooks.find(b => b.server_id === activeBookServerID);
if (!targetBook) return;
let resp, data;
try {
resp = await fetch(`/api/partnumber-books/${book.server_id}`);
const params = new URLSearchParams({
page: String(page),
per_page: String(ITEMS_PER_PAGE),
});
if (search.trim()) {
params.set('search', search.trim());
}
resp = await fetch(`/api/partnumber-books/${targetBook.server_id}?` + params.toString());
data = await resp.json();
} catch (e) {
return;
}
if (!resp.ok) return;
allItems = data.items || [];
activeItems = data.items || [];
itemsPage = data.page || page;
itemsTotal = Number(data.total || 0);
itemsSearch = data.search || search || '';
const lots = new Set(allItems.map(i => i.lot_name));
const primaryCount = allItems.filter(i => i.is_primary_pn).length;
document.getElementById('card-version').textContent = book.version;
document.getElementById('card-date').textContent = book.created_at;
document.getElementById('card-lots').textContent = lots.size;
document.getElementById('card-pn-total').textContent = allItems.length;
document.getElementById('card-pn-primary').textContent = primaryCount;
document.getElementById('card-version').textContent = targetBook.version;
document.getElementById('card-date').textContent = targetBook.created_at;
document.getElementById('card-lots').textContent = Number(data.lot_count || 0);
document.getElementById('card-pn-total').textContent = Number(data.book_total || 0);
document.getElementById('card-pn-primary').textContent = Number(data.primary_count || 0);
document.getElementById('summary-cards').classList.remove('hidden');
document.getElementById('active-book-section').classList.remove('hidden');
document.getElementById('pn-search').value = itemsSearch;
renderItems(allItems);
renderItems(activeItems);
renderItemsPagination();
}
function renderItems(items) {
@@ -218,15 +248,38 @@ function renderItems(items) {
`;
tbody.appendChild(tr);
});
document.getElementById('pn-count').textContent = `${items.length} из ${allItems.length}`;
const totalLabel = itemsSearch ? `${items.length} из ${itemsTotal} по фильтру` : `${items.length} из ${itemsTotal}`;
document.getElementById('pn-count').textContent = totalLabel;
}
function filterItems(query) {
const q = query.trim().toLowerCase();
if (!q) { renderItems(allItems); return; }
renderItems(allItems.filter(i =>
i.partnumber.toLowerCase().includes(q) || i.lot_name.toLowerCase().includes(q)
));
function renderItemsPagination() {
const totalPages = Math.max(1, Math.ceil(itemsTotal / ITEMS_PER_PAGE));
const start = itemsTotal === 0 ? 0 : ((itemsPage - 1) * ITEMS_PER_PAGE) + 1;
const end = Math.min(itemsPage * ITEMS_PER_PAGE, itemsTotal);
const box = document.getElementById('items-pagination');
if (itemsTotal > ITEMS_PER_PAGE) {
box.classList.remove('hidden');
document.getElementById('items-page-info').textContent = `Сопоставления ${start}${end} из ${itemsTotal}`;
document.getElementById('items-prev').disabled = itemsPage === 1;
document.getElementById('items-next').disabled = itemsPage >= totalPages;
} else {
box.classList.add('hidden');
}
}
function changeItemsPage(delta) {
const totalPages = Math.max(1, Math.ceil(itemsTotal / ITEMS_PER_PAGE));
const nextPage = Math.max(1, Math.min(totalPages, itemsPage + delta));
if (nextPage === itemsPage) return;
loadActiveBookItemsPage(nextPage, itemsSearch);
}
function onItemsSearchInput(value) {
clearTimeout(_itemsSearchTimer);
_itemsSearchTimer = setTimeout(() => {
itemsSearch = value.trim();
loadActiveBookItemsPage(1, itemsSearch);
}, 250);
}
async function syncPartnumberBooks() {
@@ -253,4 +306,3 @@ document.addEventListener('DOMContentLoaded', loadBooks);
{{end}}
{{template "base" .}}

View File

@@ -2,21 +2,21 @@
{{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="Все проекты">
<div class="flex items-start justify-between gap-3">
<div class="flex flex-wrap items-center gap-2 sm:gap-3 min-w-0">
<a href="/projects" class="shrink-0 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">
<div class="flex flex-wrap items-center gap-2 text-xl sm:text-2xl font-bold min-w-0">
<a id="project-code-link" href="/projects" class="min-w-0 truncate 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 class="text-gray-400 shrink-0">-</span>
<div class="relative shrink-0">
<button id="project-variant-button" type="button" class="inline-flex items-center gap-2 text-sm sm: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>
@@ -26,24 +26,24 @@
<div id="project-variant-list" class="py-1"></div>
</div>
</div>
<button onclick="openNewVariantModal()" class="inline-flex w-full sm:w-auto justify-center items-center px-3 py-1.5 text-sm font-medium bg-purple-600 text-white rounded-lg hover:bg-purple-700">
+ Вариант
</button>
</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 onclick="openVendorImportModal()" class="py-2 bg-amber-600 text-white rounded-lg hover:bg-amber-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">
<button onclick="openExportModal()" 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">
@@ -72,10 +72,10 @@
<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>
<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>
<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>
@@ -87,6 +87,65 @@
</div>
</div>
<div id="vendor-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-lg 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">
Загружает `CFXML`-выгрузку в текущий проект и создаёт несколько конфигураций, если они есть в файле.
</div>
<div>
<label for="vendor-import-file" class="block text-sm font-medium text-gray-700 mb-1">Файл выгрузки</label>
<input id="vendor-import-file" type="file" accept=".xml,text/xml,application/xml"
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-amber-500 focus:border-amber-500">
</div>
<div id="vendor-import-status" class="hidden text-sm rounded border px-3 py-2"></div>
</div>
<div class="flex justify-end space-x-3 mt-6">
<button onclick="closeVendorImportModal()" class="px-4 py-2 text-gray-600 hover:text-gray-800">Отмена</button>
<button id="vendor-import-submit" onclick="importVendorWorkspace()" class="px-4 py-2 bg-amber-600 text-white rounded hover:bg-amber-700">Импортировать</button>
</div>
</div>
</div>
<div id="project-export-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">Экспорт CSV</h2>
<div class="space-y-4">
<div class="text-sm text-gray-600">
Экспортирует проект в формате вкладки ценообразования. Если включён `BOM`, строки строятся по BOM; иначе по текущему Estimate.
</div>
<div class="space-y-3">
<label class="flex items-center gap-3 text-sm text-gray-700">
<input id="export-col-lot" type="checkbox" class="rounded border-gray-300" checked>
<span>LOT</span>
</label>
<label class="flex items-center gap-3 text-sm text-gray-700">
<input id="export-col-bom" type="checkbox" class="rounded border-gray-300">
<span>BOM</span>
</label>
<label class="flex items-center gap-3 text-sm text-gray-700">
<input id="export-col-estimate" type="checkbox" class="rounded border-gray-300" checked>
<span>Estimate</span>
</label>
<label class="flex items-center gap-3 text-sm text-gray-700">
<input id="export-col-stock" type="checkbox" class="rounded border-gray-300">
<span>Stock</span>
</label>
<label class="flex items-center gap-3 text-sm text-gray-700">
<input id="export-col-competitor" type="checkbox" class="rounded border-gray-300">
<span>Конкуренты</span>
</label>
</div>
<div id="project-export-status" class="hidden text-sm rounded border px-3 py-2"></div>
</div>
<div class="flex justify-end space-x-3 mt-6">
<button onclick="closeExportModal()" class="px-4 py-2 text-gray-600 hover:text-gray-800">Отмена</button>
<button id="project-export-submit" onclick="exportProject()" class="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700">Скачать CSV</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>
@@ -116,7 +175,7 @@
<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>
<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>
@@ -154,25 +213,6 @@
</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>
@@ -347,7 +387,7 @@ function applyStatusModeUI() {
}
function renderConfigs(configs) {
const emptyText = configStatusMode === 'archived' ? 'Архив пуст' : 'Нет квот в проекте';
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>';
@@ -540,6 +580,86 @@ function closeCreateModal() {
document.getElementById('create-modal').classList.remove('flex');
}
function setVendorImportStatus(message, type) {
const box = document.getElementById('vendor-import-status');
if (!box) return;
if (!message) {
box.textContent = '';
box.className = 'hidden text-sm rounded border px-3 py-2';
return;
}
let classes = 'text-sm rounded border px-3 py-2 ';
if (type === 'error') {
classes += 'border-red-200 bg-red-50 text-red-700';
} else if (type === 'success') {
classes += 'border-emerald-200 bg-emerald-50 text-emerald-700';
} else {
classes += 'border-amber-200 bg-amber-50 text-amber-700';
}
box.className = classes;
box.textContent = message;
}
function openVendorImportModal() {
document.getElementById('vendor-import-file').value = '';
setVendorImportStatus('', '');
document.getElementById('vendor-import-submit').disabled = false;
document.getElementById('vendor-import-submit').textContent = 'Импортировать';
document.getElementById('vendor-import-modal').classList.remove('hidden');
document.getElementById('vendor-import-modal').classList.add('flex');
}
function closeVendorImportModal() {
document.getElementById('vendor-import-modal').classList.add('hidden');
document.getElementById('vendor-import-modal').classList.remove('flex');
}
async function importVendorWorkspace() {
const input = document.getElementById('vendor-import-file');
const submit = document.getElementById('vendor-import-submit');
if (!input || !input.files || !input.files[0]) {
setVendorImportStatus('Выберите XML-файл выгрузки', 'error');
return;
}
const formData = new FormData();
formData.append('file', input.files[0]);
submit.disabled = true;
submit.textContent = 'Импорт...';
setVendorImportStatus('Импортирую файл в проект...', 'info');
try {
const resp = await fetch('/api/projects/' + projectUUID + '/vendor-import', {
method: 'POST',
body: formData
});
const data = await resp.json().catch(() => ({}));
if (!resp.ok) {
throw new Error(data.error || 'Не удалось импортировать выгрузку');
}
const imported = Array.isArray(data.configs) ? data.configs : [];
const importedNames = imported.map(c => c.name).filter(Boolean);
const message = importedNames.length
? 'Импортировано ' + imported.length + ': ' + importedNames.join(', ')
: 'Импортировано конфигураций: ' + (data.imported || 0);
setVendorImportStatus(message, 'success');
if (typeof showToast === 'function') {
showToast('Импорт завершён', 'success');
}
await loadConfigs();
setTimeout(closeVendorImportModal, 900);
} catch (e) {
setVendorImportStatus(e.message || 'Не удалось импортировать выгрузку', 'error');
if (typeof showToast === 'function') {
showToast(e.message || 'Не удалось импортировать выгрузку', 'error');
}
} finally {
submit.disabled = false;
submit.textContent = 'Импортировать';
}
}
async function createConfig() {
const name = document.getElementById('create-name').value.trim();
if (!name) {
@@ -552,7 +672,7 @@ async function createConfig() {
body: JSON.stringify({name: name, items: [], notes: '', server_count: 1})
});
if (!resp.ok) {
alert('Не удалось создать квоту');
alert('Не удалось создать конфигурацию');
return;
}
closeCreateModal();
@@ -560,7 +680,7 @@ async function createConfig() {
}
async function deleteConfig(uuid) {
if (!confirm('Переместить квоту в архив?')) return;
if (!confirm('Переместить конфигурацию в архив?')) return;
await fetch('/api/configs/' + uuid, {method: 'DELETE'});
loadConfigs();
}
@@ -568,7 +688,7 @@ async function deleteConfig(uuid) {
async function reactivateConfig(uuid) {
const resp = await fetch('/api/configs/' + uuid + '/reactivate', {method: 'POST'});
if (!resp.ok) {
alert('Не удалось восстановить квоту');
alert('Не удалось восстановить конфигурацию');
return;
}
const moved = await fetch('/api/configs/' + uuid + '/project', {
@@ -577,7 +697,7 @@ async function reactivateConfig(uuid) {
body: JSON.stringify({project_uuid: projectUUID})
});
if (!moved.ok) {
alert('Квота восстановлена, но не удалось вернуть в проект');
alert('Конфигурация восстановлена, но не удалось вернуть в проект');
}
loadConfigs();
}
@@ -752,7 +872,7 @@ async function saveConfigAction() {
body: JSON.stringify({name: name})
});
if (!cloneResp.ok) {
notify('Не удалось скопировать квоту', 'error');
notify('Не удалось скопировать конфигурацию', 'error');
return;
}
closeConfigActionModal();
@@ -773,7 +893,7 @@ async function saveConfigAction() {
body: JSON.stringify({name: name})
});
if (!renameResp.ok) {
notify('Не удалось переименовать квоту', 'error');
notify('Не удалось переименовать конфигурацию', 'error');
return;
}
changed = true;
@@ -786,7 +906,7 @@ async function saveConfigAction() {
body: JSON.stringify({project_uuid: targetProjectUUID})
});
if (!moveResp.ok) {
notify('Не удалось перенести квоту', 'error');
notify('Не удалось перенести конфигурацию', 'error');
return;
}
changed = true;
@@ -805,21 +925,6 @@ async function saveConfigAction() {
}
}
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) {
@@ -879,89 +984,10 @@ async function saveProjectSettings() {
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;
if (!confirm('Удалить вариант «' + variantLabel + '»? Все конфигурации будут архивированы.')) return;
const resp = await fetch('/api/projects/' + projectUUID, {method: 'DELETE'});
if (!resp.ok) {
const data = await resp.json().catch(() => ({}));
@@ -988,9 +1014,9 @@ function updateDeleteVariantButton() {
}
document.getElementById('create-modal').addEventListener('click', function(e) { if (e.target === this) closeCreateModal(); });
document.getElementById('vendor-import-modal').addEventListener('click', function(e) { if (e.target === this) closeVendorImportModal(); });
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);
@@ -1007,8 +1033,8 @@ document.getElementById('config-action-copy').addEventListener('change', functio
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
closeCreateModal();
closeVendorImportModal();
closeConfigActionModal();
closeImportModal();
closeProjectSettingsModal();
}
});
@@ -1162,8 +1188,82 @@ function updateFooterTotal() {
}
}
function exportProject() {
window.location.href = '/api/projects/' + projectUUID + '/export';
function openExportModal() {
const modal = document.getElementById('project-export-modal');
if (!modal) return;
setProjectExportStatus('', '');
modal.classList.remove('hidden');
modal.classList.add('flex');
}
function closeExportModal() {
const modal = document.getElementById('project-export-modal');
if (!modal) return;
modal.classList.add('hidden');
modal.classList.remove('flex');
}
function setProjectExportStatus(message, tone) {
const status = document.getElementById('project-export-status');
if (!status) return;
if (!message) {
status.className = 'hidden text-sm rounded border px-3 py-2';
status.textContent = '';
return;
}
const palette = tone === 'error'
? 'text-red-700 bg-red-50 border-red-200'
: 'text-gray-700 bg-gray-50 border-gray-200';
status.className = 'text-sm rounded border px-3 py-2 ' + palette;
status.textContent = message;
}
async function exportProject() {
const submitBtn = document.getElementById('project-export-submit');
const payload = {
include_lot: !!document.getElementById('export-col-lot')?.checked,
include_bom: !!document.getElementById('export-col-bom')?.checked,
include_estimate: !!document.getElementById('export-col-estimate')?.checked,
include_stock: !!document.getElementById('export-col-stock')?.checked,
include_competitor: !!document.getElementById('export-col-competitor')?.checked
};
if (submitBtn) submitBtn.disabled = true;
setProjectExportStatus('', '');
try {
const resp = await fetch('/api/projects/' + projectUUID + '/export', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(payload)
});
if (!resp.ok) {
let message = 'Не удалось экспортировать CSV';
try {
const data = await resp.json();
if (data && data.error) message = data.error;
} catch (_) {}
setProjectExportStatus(message, 'error');
return;
}
const blob = await resp.blob();
const link = document.createElement('a');
const url = window.URL.createObjectURL(blob);
const disposition = resp.headers.get('Content-Disposition') || '';
const match = disposition.match(/filename="([^"]+)"/);
link.href = url;
link.download = match ? match[1] : 'project-export.csv';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
closeExportModal();
} catch (e) {
setProjectExportStatus('Ошибка сети при экспорте CSV', 'error');
} finally {
if (submitBtn) submitBtn.disabled = false;
}
}
document.addEventListener('DOMContentLoaded', async function() {

View File

@@ -315,7 +315,7 @@ async function loadProjects() {
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>';
html += '</button>';
html += '<button onclick="addConfigToProject(\'' + p.uuid + '\')" class="text-indigo-700 hover:text-indigo-900" title="Добавить квоту">';
html += '<button onclick="addConfigToProject(\'' + p.uuid + '\')" class="text-indigo-700 hover:text-indigo-900" title="Добавить конфигурацию">';
html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path></svg>';
html += '</button>';
} else {
@@ -472,7 +472,7 @@ async function reactivateProject(projectUUID) {
}
async function addConfigToProject(projectUUID) {
const name = prompt('Название новой квоты');
const name = prompt('Название новой конфигурации');
if (!name || !name.trim()) return;
const resp = await fetch('/api/projects/' + projectUUID + '/configs', {
method: 'POST',
@@ -480,7 +480,7 @@ async function addConfigToProject(projectUUID) {
body: JSON.stringify({name: name.trim(), items: [], notes: '', server_count: 1})
});
if (!resp.ok) {
alert('Не удалось создать квоту');
alert('Не удалось создать конфигурацию');
return;
}
loadProjects();
@@ -510,7 +510,7 @@ async function copyProject(projectUUID, projectName) {
const listResp = await fetch('/api/projects/' + projectUUID + '/configs');
if (!listResp.ok) {
alert('Проект скопирован без квот (не удалось загрузить исходные квоты)');
alert('Проект скопирован без конфигураций (не удалось загрузить исходные конфигурации)');
loadProjects();
return;
}