Files
QuoteForge/web/templates/index.html
2026-03-07 21:03:40 +03:00

3907 lines
160 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{{define "title"}}QuoteForge - Конфигуратор{{end}}
{{define "content"}}
<div class="space-y-4">
<!-- Header with config name and back button -->
<div class="flex items-center justify-between">
<div class="flex items-center space-x-4">
<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" id="config-breadcrumbs">
<a id="breadcrumb-project-code-link" href="/projects" class="text-blue-700 hover:underline">
<span id="breadcrumb-project-code"></span>
</a>
<span class="text-gray-400">-</span>
<a id="breadcrumb-project-variant-link" href="/projects" class="text-blue-700 hover:underline">
<span id="breadcrumb-project-variant">main</span>
</a>
<span class="text-gray-400">-</span>
<a id="breadcrumb-config-name-link" href="#" class="text-blue-700 hover:underline">
<span id="breadcrumb-config-name">Конфигуратор</span>
</a>
<span class="text-gray-400">-</span>
<span id="breadcrumb-config-version">v1</span>
</div>
</div>
<div id="save-buttons" class="hidden flex items-center space-x-2">
<button id="refresh-prices-btn" onclick="refreshPrices()" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">
Обновить цены
</button>
<button type="button"
onclick="openPriceSettingsModal()"
class="h-10 px-3 bg-gray-100 text-gray-700 rounded hover:bg-gray-200 border border-gray-300 inline-flex items-center justify-center"
title="Настройки цен">
Цены
</button>
<button onclick="saveConfig()" class="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700">
Сохранить
</button>
<span id="price-update-date" class="text-sm text-gray-500"></span>
</div>
</div>
<!-- Server count input -->
<div class="bg-white rounded-lg shadow p-4 mb-4">
<div class="flex items-center space-x-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Количество серверов</label>
<input type="number" id="server-count" min="1" value="1"
class="w-20 px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500"
onchange="updateServerCount()">
</div>
<div class="text-sm text-gray-600">
<div class="font-medium text-gray-700 mb-1">Прайслисты цен</div>
<div id="pricelist-settings-summary">Estimate: авто, Склад: авто, Конкуренты: авто</div>
</div>
<div class="text-sm text-gray-500">
<span id="server-count-info">Всего: <span id="total-server-count">1</span> сервер(а)</span>
</div>
</div>
</div>
<!-- Top-level tabs: Estimate | BOM | Ценообразование -->
<div class="bg-white rounded-lg shadow mb-0">
<nav class="flex border-b">
<button id="top-tab-estimate" onclick="switchTopTab('estimate')"
class="px-5 py-3 text-sm font-semibold border-b-2 border-blue-600 text-blue-600">
Estimate
</button>
<button id="top-tab-bom" onclick="switchTopTab('bom')"
class="px-5 py-3 text-sm font-semibold border-b-2 border-transparent text-gray-500 hover:text-gray-700">
BOM
</button>
<button id="top-tab-pricing" onclick="switchTopTab('pricing')"
class="px-5 py-3 text-sm font-semibold border-b-2 border-transparent text-gray-500 hover:text-gray-700">
Ценообразование
</button>
</nav>
</div>
<!-- Top-tab section: Estimate -->
<div id="top-section-estimate">
<!-- Category Tabs -->
<div class="bg-white rounded-lg shadow">
<div class="border-b">
<nav class="flex overflow-x-auto" id="category-tabs">
<button onclick="switchTab('base')" data-tab="base"
class="tab-btn px-4 py-3 text-sm font-medium border-b-2 border-blue-600 text-blue-600 whitespace-nowrap">
Base
</button>
<button onclick="switchTab('storage')" data-tab="storage"
class="tab-btn px-4 py-3 text-sm font-medium border-b-2 border-transparent text-gray-500 hover:text-gray-700 whitespace-nowrap">
Storage
</button>
<button onclick="switchTab('pci')" data-tab="pci"
class="tab-btn px-4 py-3 text-sm font-medium border-b-2 border-transparent text-gray-500 hover:text-gray-700 whitespace-nowrap">
PCI
</button>
<button onclick="switchTab('power')" data-tab="power"
class="tab-btn px-4 py-3 text-sm font-medium border-b-2 border-transparent text-gray-500 hover:text-gray-700 whitespace-nowrap">
Power
</button>
<button onclick="switchTab('accessories')" data-tab="accessories"
class="tab-btn px-4 py-3 text-sm font-medium border-b-2 border-transparent text-gray-500 hover:text-gray-700 whitespace-nowrap">
Accessories
</button>
<button onclick="switchTab('other')" data-tab="other"
class="tab-btn px-4 py-3 text-sm font-medium border-b-2 border-transparent text-gray-500 hover:text-gray-700 whitespace-nowrap">
Other
</button>
</nav>
</div>
<!-- Tab content -->
<div id="tab-content" class="p-4">
<div class="text-center py-8 text-gray-500">Загрузка...</div>
</div>
</div>
<!-- Cart summary -->
<div id="cart-summary" class="bg-white rounded-lg shadow overflow-hidden">
<button type="button"
onclick="toggleCartSummarySection()"
class="w-full px-4 py-3 flex items-center justify-between text-blue-900 bg-gradient-to-r from-blue-100 to-blue-50 hover:from-blue-200 hover:to-blue-100 border-b border-blue-200">
<span class="font-semibold">Итого конфигурация</span>
<svg id="cart-summary-toggle-icon" class="w-5 h-5 transition-transform duration-200" 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="cart-summary-content" class="p-4">
<div id="article-display" class="text-sm text-gray-700 mb-3 font-mono"></div>
<div id="cart-items" class="space-y-2 mb-4"></div>
<div class="border-t pt-3 flex justify-between items-center">
<div class="text-lg font-bold">
Итого: <span id="cart-total">$0.00</span>
</div>
<button onclick="exportCSV()" class="px-3 py-1 bg-gray-200 text-gray-700 rounded text-sm hover:bg-gray-300">Экспорт CSV</button>
</div>
</div>
</div>
<!-- hidden inputs kept for JS compatibility -->
<input type="hidden" id="custom-price-input" value="">
<div id="adjusted-prices" class="hidden"></div>
<div id="discount-info" class="hidden"></div>
<div id="sale-prices" class="hidden"></div>
</div><!-- end top-section-estimate -->
<!-- Top-tab section: BOM -->
<div id="top-section-bom" class="hidden">
<div class="bg-white rounded-lg shadow p-4">
<div class="mb-3">
<p class="text-sm font-medium text-gray-700 mb-2">Вставьте таблицу из Excel (Ctrl+V в область ниже)</p>
<div class="bg-gray-50 border border-gray-200 rounded p-3 text-xs text-gray-500 space-y-1">
<p class="font-medium text-gray-600">После вставки выберите тип для каждого столбца: P/N, Кол-во, Цена, Описание или «Не использовать».</p>
<p>Цена поддерживает форматы: <span class="font-mono">$5114,00</span> · <span class="font-mono">5 114.00</span> · <span class="font-mono">5114</span></p>
<p class="text-gray-400">Строку заголовка можно игнорировать или удалить. LOT сопоставляется автоматически по колонке P/N.</p>
</div>
</div>
<div id="bom-paste-area"
contenteditable="true"
tabindex="0"
class="border-2 border-dashed border-gray-300 rounded-lg p-4 min-h-16 text-gray-400 focus:outline-none focus:border-blue-400 cursor-text mb-4"
onpaste="handleBOMPaste(event)"
placeholder="Нажмите сюда и вставьте из Excel (Ctrl+V)...">
Нажмите сюда и вставьте из Excel (Ctrl+V)...
</div>
<!-- BOM table -->
<div id="bom-table-container" class="hidden">
<div class="overflow-x-auto">
<table class="w-full text-sm border-collapse">
<thead id="bom-table-head" class="bg-gray-50 text-gray-700"></thead>
<tbody id="bom-table-body"></tbody>
</table>
</div>
<div id="bom-errors" class="mt-2 text-xs text-red-600 hidden"></div>
<div class="mt-3 flex items-center justify-between text-sm text-gray-600">
<div id="bom-stats"></div>
<div class="flex gap-2">
<button onclick="clearBOM()" class="px-3 py-1 bg-gray-100 text-gray-600 rounded hover:bg-gray-200 border border-gray-300">
Очистить
</button>
<button onclick="saveBOM()" class="px-3 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">
Сохранить BOM
</button>
<button onclick="applyBOMToEstimate()" class="px-3 py-1 bg-orange-600 text-white rounded hover:bg-orange-700">
Пересчитать эстимейт
</button>
</div>
</div>
</div>
</div>
</div><!-- end top-section-bom -->
<!-- Top-tab section: Ценообразование -->
<div id="top-section-pricing" class="hidden">
<div class="bg-white rounded-lg shadow p-4">
<div id="pricing-table-container">
<div class="overflow-x-auto">
<table class="w-full text-sm border-collapse">
<thead class="bg-gray-50 text-gray-700">
<tr>
<th class="px-3 py-2 text-left border-b">LOT</th>
<th class="px-3 py-2 text-left border-b">PN вендора</th>
<th class="px-3 py-2 text-left border-b">Описание</th>
<th class="px-3 py-2 text-right border-b">Кол-во</th>
<th class="px-3 py-2 text-right border-b">Estimate</th>
<th class="px-3 py-2 text-right border-b">Цена проектная</th>
<th class="px-3 py-2 text-right border-b">Склад</th>
<th class="px-3 py-2 text-right border-b">Конк.</th>
</tr>
</thead>
<tbody id="pricing-table-body">
<tr><td colspan="8" class="px-3 py-8 text-center text-gray-400">Загрузите BOM во вкладке «BOM»</td></tr>
</tbody>
<tfoot id="pricing-table-foot" class="hidden bg-gray-50 font-semibold">
<tr>
<td colspan="4" class="px-3 py-2 text-right">Итого:</td>
<td class="px-3 py-2 text-right" id="pricing-total-estimate"></td>
<td class="px-3 py-2 text-right font-bold" id="pricing-total-vendor"></td>
<td class="px-3 py-2 text-right" id="pricing-total-warehouse"></td>
<td class="px-3 py-2 text-right"></td>
</tr>
</tfoot>
</table>
</div>
<div class="mt-4 flex flex-wrap items-center gap-4">
<label class="text-sm font-medium text-gray-700">Своя цена:</label>
<input type="number" id="pricing-custom-price" step="0.01" min="0" placeholder="0.00"
class="w-40 px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500"
oninput="onPricingCustomPriceInput()">
<label class="text-sm font-medium text-gray-700">Uplift:</label>
<input type="text" id="pricing-uplift" inputmode="decimal" placeholder="1,0000"
class="w-32 px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500"
oninput="onPricingUpliftInput()">
<button onclick="setPricingCustomPriceFromVendor()" class="px-3 py-2 bg-gray-100 text-gray-700 rounded hover:bg-gray-200 border border-gray-300 text-sm">
Проставить цены BOM
</button>
<button onclick="exportPricingCSV()" class="px-3 py-2 bg-green-600 text-white rounded hover:bg-green-700 text-sm">
Экспорт CSV
</button>
<span id="pricing-discount-info" class="text-sm text-gray-500 hidden">
Скидка от Estimate: <span id="pricing-discount-pct" class="font-semibold text-green-600"></span>
</span>
</div>
</div>
</div>
</div><!-- end top-section-pricing -->
</div>
<!-- Price settings modal -->
<div id="price-settings-modal" class="hidden fixed inset-0 z-50">
<div class="absolute inset-0 bg-black/40" onclick="closePriceSettingsModal()"></div>
<div class="relative max-w-xl mx-auto mt-24 bg-white rounded-lg shadow-xl border">
<div class="px-5 py-4 border-b flex items-center justify-between">
<h3 class="text-lg font-semibold text-gray-900">Настройки цен</h3>
<button type="button" onclick="closePriceSettingsModal()" class="text-gray-500 hover:text-gray-700">
<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="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
<div class="px-5 py-4 space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Estimate</label>
<select id="settings-pricelist-estimate" class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500"></select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Склад</label>
<select id="settings-pricelist-warehouse" class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500"></select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Конкуренты</label>
<select id="settings-pricelist-competitor" class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500"></select>
</div>
<label class="flex items-center gap-2 text-sm text-gray-700">
<input id="settings-disable-price-refresh" type="checkbox" class="rounded border-gray-300 text-blue-600 focus:ring-blue-500">
<span>Не обновлять цены</span>
</label>
<label class="flex items-center gap-2 text-sm text-gray-700">
<input id="settings-only-in-stock" type="checkbox" class="rounded border-gray-300 text-blue-600 focus:ring-blue-500">
<span>Только наличие</span>
</label>
</div>
<div class="px-5 py-4 border-t flex justify-end gap-2">
<button type="button" onclick="closePriceSettingsModal()" class="px-4 py-2 bg-gray-100 text-gray-700 rounded hover:bg-gray-200">Отмена</button>
<button type="button" onclick="applyPriceSettings()" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">Применить</button>
</div>
</div>
</div>
<!-- Autocomplete dropdown (shared) -->
<div id="autocomplete-dropdown" class="hidden absolute z-50 bg-white border rounded-lg shadow-lg max-h-96 overflow-y-auto w-96"></div>
<style>
.autocomplete-item {
padding: 8px 12px;
cursor: pointer;
border-bottom: 1px solid #f0f0f0;
}
.autocomplete-item:hover, .autocomplete-item.selected {
background-color: #f3f4f6;
}
.autocomplete-item:last-child {
border-bottom: none;
}
</style>
<script>
// Tab configuration - will be populated dynamically
let TAB_CONFIG = {
base: {
categories: ['MB', 'CPU', 'MEM'],
singleSelect: true,
label: 'Base'
},
storage: {
categories: ['RAID', 'M2', 'SSD', 'HDD', 'EDSFF', 'HHHL'],
singleSelect: false,
label: 'Storage',
sections: [
{ title: 'RAID Контроллеры', categories: ['RAID'] },
{ title: 'Диски', categories: ['M2', 'SSD', 'HDD', 'EDSFF', 'HHHL'] }
]
},
pci: {
categories: ['GPU', 'DPU', 'NIC', 'HCA', 'HBA'],
singleSelect: false,
label: 'PCI',
sections: [
{ title: 'GPU / DPU', categories: ['GPU', 'DPU'] },
{ title: 'NIC / HCA', categories: ['NIC', 'HCA'] },
{ title: 'HBA', categories: ['HBA'] }
]
},
power: {
categories: ['PS', 'PSU'],
singleSelect: false,
label: 'Power'
},
accessories: {
categories: ['ACC', 'CARD'],
singleSelect: false,
label: 'Accessories'
},
other: {
categories: [],
singleSelect: false,
label: 'Other'
}
};
let ASSIGNED_CATEGORIES = Object.values(TAB_CONFIG)
.flatMap(t => t.categories)
.map(c => c.toUpperCase());
// State
let configUUID = '{{.ConfigUUID}}';
let configName = '';
let currentVersionNo = 1;
let projectUUID = '';
let projectName = '';
let projectCode = '';
let projectVariant = '';
let projectIndexLoaded = false;
let projectByUUID = {};
let projectMainByCode = {};
async function loadProjectIndex() {
if (projectIndexLoaded) return;
try {
const resp = await fetch('/api/projects/all');
if (!resp.ok) return;
const data = await resp.json();
const allProjects = Array.isArray(data) ? data : (data.projects || []);
projectByUUID = {};
projectMainByCode = {};
allProjects.forEach(p => {
projectByUUID[p.uuid] = p;
const code = (p.code || '').trim();
const variant = (p.variant || '').trim();
if (code && (variant === '' || variant === 'main')) {
if (!projectMainByCode[code]) {
projectMainByCode[code] = p.uuid;
}
}
});
projectIndexLoaded = true;
} catch (e) {
// ignore
}
}
function updateConfigBreadcrumbs() {
const codeEl = document.getElementById('breadcrumb-project-code');
const variantEl = document.getElementById('breadcrumb-project-variant');
const configEl = document.getElementById('breadcrumb-config-name');
const versionEl = document.getElementById('breadcrumb-config-version');
const projectCodeLinkEl = document.getElementById('breadcrumb-project-code-link');
const projectVariantLinkEl = document.getElementById('breadcrumb-project-variant-link');
let code = 'Без проекта';
let variant = 'main';
if (projectUUID && projectByUUID[projectUUID]) {
code = (projectByUUID[projectUUID].code || '').trim() || 'Без проекта';
const rawVariant = (projectByUUID[projectUUID].variant || '').trim();
variant = rawVariant === '' ? 'main' : rawVariant;
if (projectCodeLinkEl) {
const mainUUID = projectMainByCode[code];
projectCodeLinkEl.href = mainUUID ? ('/projects/' + mainUUID) : ('/projects/' + projectUUID);
}
if (projectVariantLinkEl) {
projectVariantLinkEl.href = '/projects/' + projectUUID;
}
} else {
if (projectCodeLinkEl) projectCodeLinkEl.href = '/projects';
if (projectVariantLinkEl) projectVariantLinkEl.href = '/projects';
}
codeEl.textContent = code;
variantEl.textContent = variant;
const fullConfigName = configName || 'Конфигурация';
configEl.textContent = truncateBreadcrumbSpecName(fullConfigName);
configEl.title = fullConfigName;
versionEl.textContent = 'v' + (currentVersionNo || 1);
const configNameLinkEl = document.getElementById('breadcrumb-config-name-link');
if (configNameLinkEl && configUUID) {
configNameLinkEl.href = '/configs/' + configUUID + '/revisions';
}
}
function truncateBreadcrumbSpecName(name) {
const maxLength = 16;
if (!name || name.length <= maxLength) return name;
return name.slice(0, maxLength - 1) + '…';
}
let currentTab = 'base';
let allComponents = [];
let cart = [];
let categoryOrderMap = {}; // Category code -> display_order mapping
let autoSaveTimeout = null; // Timeout for debounced autosave
let hasUnsavedChanges = false;
let exitSaveStarted = false;
let serverCount = 1; // Server count for the configuration
let serverModelForQuote = '';
let supportCode = '';
let currentArticle = '';
let articlePreviewTimeout = null;
let selectedPricelistIds = {
estimate: null,
warehouse: null,
competitor: null
};
let resolvedAutoPricelistIds = {
estimate: null,
warehouse: null,
competitor: null
};
let disablePriceRefresh = false;
let onlyInStock = false;
let activePricelistsBySource = {
estimate: [],
warehouse: [],
competitor: []
};
let activePricelistsLoadedAt = 0;
let activePricelistsLoadPromise = null;
let priceLevelsRequestSeq = 0;
let priceLevelsRefreshTimer = null;
let warehouseStockLotsByPricelist = new Map();
let warehouseStockLoadSeq = 0;
let warehouseStockLoadsByPricelist = new Map();
let componentPricesCache = {}; // { lot_name: price } - caches prices loaded via API
let componentPricesCacheLoading = new Map(); // { category: Promise } - tracks ongoing price loads
// Autocomplete state
let autocompleteInput = null;
let autocompleteCategory = null;
let autocompleteMode = null; // 'single', 'multi', 'section'
let autocompleteIndex = -1;
let autocompleteFiltered = [];
function getDisplayPrice(item) {
if (typeof item.unit_price === 'number' && item.unit_price > 0) {
return item.unit_price;
}
if (typeof item.estimate_price === 'number' && item.estimate_price > 0) {
return item.estimate_price;
}
return 0;
}
function formatNumberRu(value) {
const rounded = Math.round(value);
return rounded
.toLocaleString('ru-RU', { minimumFractionDigits: 0, maximumFractionDigits: 0 })
.replace(/[\u202f\u00a0 ]/g, '\u00A0');
}
function formatMoney(value) {
return '$\u00A0' + formatNumberRu(value);
}
function formatPriceOrNA(value) {
if (typeof value !== 'number' || value <= 0) {
return 'N/A';
}
return formatMoney(value);
}
function formatDelta(abs, pct) {
if (typeof abs !== 'number') {
return 'N/A';
}
const sign = abs > 0 ? '+' : abs < 0 ? '-' : '';
const absValue = Math.abs(abs);
if (typeof pct !== 'number') {
return sign + formatMoney(absValue);
}
const pctSign = pct > 0 ? '+' : pct < 0 ? '-' : '';
return sign + formatMoney(absValue) + ' (' + pctSign + Math.round(Math.abs(pct)) + '%)';
}
function getEffectivePricelistID(source) {
const explicit = selectedPricelistIds[source];
if (Number.isFinite(explicit) && explicit > 0) {
return Number(explicit);
}
const resolvedAuto = resolvedAutoPricelistIds[source];
if (Number.isFinite(resolvedAuto) && resolvedAuto > 0) {
return Number(resolvedAuto);
}
const fallback = activePricelistsBySource[source]?.[0]?.id;
if (Number.isFinite(fallback) && fallback > 0) {
return Number(fallback);
}
return null;
}
async function refreshPriceLevels(options = {}) {
const force = options.force === true;
const noCache = options.noCache === true;
if (!configUUID || cart.length === 0 || (disablePriceRefresh && !force)) {
return;
}
const seq = ++priceLevelsRequestSeq;
try {
const payload = {
items: cart.map(item => ({
lot_name: item.lot_name,
quantity: item.quantity
})),
no_cache: noCache,
pricelist_ids: Object.fromEntries(
Object.entries(selectedPricelistIds)
.filter(([, id]) => typeof id === 'number' && id > 0)
)
};
const resp = await fetch('/api/quote/price-levels', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!resp.ok) {
return;
}
const data = await resp.json();
if (seq !== priceLevelsRequestSeq) {
return;
}
const byLot = new Map((data.items || []).map(i => [i.lot_name, i]));
cart = cart.map(item => {
const levels = byLot.get(item.lot_name);
if (!levels) return item;
const next = { ...item, ...levels };
if (typeof levels.estimate_price === 'number' && levels.estimate_price > 0) {
next.unit_price = levels.estimate_price;
}
return next;
});
if (data.resolved_pricelist_ids) {
['estimate', 'warehouse', 'competitor'].forEach(source => {
if (!selectedPricelistIds[source] && data.resolved_pricelist_ids[source]) {
resolvedAutoPricelistIds[source] = Number(data.resolved_pricelist_ids[source]);
}
});
renderPricelistSettingsSummary();
}
} catch(e) {
console.error('Failed to refresh price levels', e);
}
}
function schedulePriceLevelsRefresh(options = {}) {
const delay = Number.isFinite(options.delay) ? options.delay : 120;
const rerender = options.rerender !== false;
const autosave = options.autosave === true;
const noCache = options.noCache === true;
const force = options.force === true;
if (priceLevelsRefreshTimer) {
clearTimeout(priceLevelsRefreshTimer);
priceLevelsRefreshTimer = null;
}
priceLevelsRefreshTimer = setTimeout(async () => {
priceLevelsRefreshTimer = null;
await refreshPriceLevels({ noCache, force });
if (rerender) {
renderTab();
updateCartUI();
}
if (autosave) {
triggerAutoSave();
}
}, Math.max(0, delay));
}
function currentWarehousePricelistID() {
return getEffectivePricelistID('warehouse');
}
async function loadWarehouseInStockLots() {
const pricelistID = currentWarehousePricelistID();
if (!pricelistID) return new Set();
if (warehouseStockLotsByPricelist.has(pricelistID)) {
return warehouseStockLotsByPricelist.get(pricelistID);
}
const existingLoad = warehouseStockLoadsByPricelist.get(pricelistID);
if (existingLoad) {
return existingLoad;
}
const loadPromise = (async () => {
const seq = ++warehouseStockLoadSeq;
const result = new Set();
const resp = await fetch(`/api/pricelists/${pricelistID}/lots`);
if (!resp.ok) {
throw new Error(`warehouse lots request failed: ${resp.status}`);
}
const data = await resp.json();
const lotNames = Array.isArray(data.lot_names) ? data.lot_names : [];
lotNames.forEach(lot => {
if (typeof lot === 'string' && lot.trim() !== '') {
result.add(lot);
}
});
if (seq === warehouseStockLoadSeq) {
warehouseStockLotsByPricelist.set(pricelistID, result);
}
return result;
})();
warehouseStockLoadsByPricelist.set(pricelistID, loadPromise);
try {
return await loadPromise;
} finally {
warehouseStockLoadsByPricelist.delete(pricelistID);
}
}
async function ensureWarehouseStockFilterLoaded() {
if (!onlyInStock) return;
try {
await loadWarehouseInStockLots();
} catch (e) {
console.error('Failed to load warehouse availability filter', e);
showToast('Не удалось загрузить наличие склада', 'error');
}
}
function isComponentAllowedByStockFilter(comp) {
if (!onlyInStock) return true;
const pricelistID = currentWarehousePricelistID();
if (!pricelistID) return false;
const availableLots = warehouseStockLotsByPricelist.get(pricelistID);
// Don't block UI while stock set is being loaded.
if (!availableLots) return true;
return availableLots.has(comp.lot_name);
}
// Load categories from API and update tab configuration
async function loadCategoriesFromAPI() {
try {
const resp = await fetch('/api/categories');
const cats = await resp.json();
// Build category order map
categoryOrderMap = {};
cats.forEach(cat => {
categoryOrderMap[cat.code.toUpperCase()] = cat.display_order;
});
// Build list of unassigned categories
const knownCodes = Object.values(TAB_CONFIG)
.flatMap(t => t.categories)
.map(c => c.toUpperCase());
const unassignedCategories = cats
.filter(cat => !knownCodes.includes(cat.code.toUpperCase()))
.sort((a, b) => a.display_order - b.display_order)
.map(cat => cat.code);
// Update "other" tab with unassigned categories
TAB_CONFIG.other.categories = unassignedCategories;
// Rebuild ASSIGNED_CATEGORIES
ASSIGNED_CATEGORIES = Object.values(TAB_CONFIG)
.flatMap(t => t.categories)
.map(c => c.toUpperCase());
} catch(e) {
console.error('Failed to load categories, using defaults', e);
// Will use default configuration if API fails
}
}
// Initialize
document.addEventListener('DOMContentLoaded', async function() {
// RBAC disabled - no token check required
if (!configUUID) {
window.location.href = '/configs';
return;
}
// Load categories in background (defaults are usable immediately).
const categoriesPromise = loadCategoriesFromAPI().catch(() => {});
try {
const resp = await fetch('/api/configs/' + configUUID);
if (resp.status === 404) {
showToast('Конфигурация не найдена', 'error');
window.location.href = '/configs';
return;
}
const config = await resp.json();
configName = config.name;
currentVersionNo = config.current_version_no || 1;
projectUUID = config.project_uuid || '';
await loadProjectIndex();
updateConfigBreadcrumbs();
document.getElementById('save-buttons').classList.remove('hidden');
// Set server count from config
serverCount = config.server_count || 1;
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) {
cart = config.items.map(item => ({
lot_name: item.lot_name,
quantity: item.quantity,
unit_price: item.unit_price,
estimate_price: item.unit_price,
warehouse_price: null,
competitor_price: null,
description: item.description || '',
category: item.category || getCategoryFromLotName(item.lot_name)
}));
}
serverModelForQuote = config.server_model || '';
supportCode = config.support_code || '';
currentArticle = config.article || '';
// Restore custom price if saved
if (config.custom_price) {
document.getElementById('custom-price-input').value = config.custom_price.toFixed(2);
}
// Display price update date if available
if (config.price_updated_at) {
updatePriceUpdateDate(config.price_updated_at);
}
} catch(e) {
showToast('Ошибка загрузки конфигурации', 'error');
window.location.href = '/configs';
return;
}
restoreLocalPriceSettings();
await Promise.all([
loadActivePricelists(),
loadAllComponents(),
categoriesPromise,
]);
syncPriceSettingsControls();
renderPricelistSettingsSummary();
updateRefreshPricesButtonState();
renderTab();
updateCartUI();
ensureWarehouseStockFilterLoaded().then(() => {
renderTab();
updateCartUI();
});
schedulePriceLevelsRefresh({ delay: 0, rerender: true, autosave: false });
// Close autocomplete on outside click
document.addEventListener('click', function(e) {
if (!e.target.closest('.autocomplete-wrapper')) {
hideAutocomplete();
}
});
// Load vendor spec BOM for this configuration
if (configUUID) {
loadVendorSpec(configUUID);
}
});
async function loadAllComponents() {
try {
const resp = await fetch('/api/components?per_page=5000');
const data = await resp.json();
allComponents = data.components || [];
window._bomAllComponents = allComponents;
} catch(e) {
console.error('Failed to load components', e);
allComponents = [];
window._bomAllComponents = [];
}
}
function _bomLots() {
return [...new Set((window._bomAllComponents || allComponents).map(c => c.lot_name).filter(Boolean))].sort();
}
const BOM_LOT_DATALIST_DIVIDER = '────────';
function _bomLotValid(v) {
const lot = (v || '').trim();
if (!lot || lot === BOM_LOT_DATALIST_DIVIDER) return false;
return (window._bomAllComponents || allComponents).some(c => c.lot_name === lot);
}
function updateServerCount() {
const serverCountInput = document.getElementById('server-count');
const newCount = parseInt(serverCountInput.value) || 1;
serverCount = Math.max(1, newCount);
serverCountInput.value = serverCount;
// Update total server count display
document.getElementById('total-server-count').textContent = serverCount;
// Update cart UI to reflect the server count
updateCartUI();
// Trigger auto-save
triggerAutoSave();
}
async function loadActivePricelists(force = false) {
const now = Date.now();
const isFresh = (now - activePricelistsLoadedAt) < 15000;
if (!force && isFresh) {
return;
}
if (activePricelistsLoadPromise) {
await activePricelistsLoadPromise;
return;
}
const sources = ['estimate', 'warehouse', 'competitor'];
activePricelistsLoadPromise = (async () => {
await Promise.all(sources.map(async source => {
try {
const resp = await fetch(`/api/pricelists?active_only=true&source=${source}&per_page=200`);
const data = await resp.json();
activePricelistsBySource[source] = data.pricelists || [];
const existing = selectedPricelistIds[source];
if (existing && activePricelistsBySource[source].some(pl => Number(pl.id) === Number(existing))) {
return;
}
selectedPricelistIds[source] = null;
} catch (e) {
activePricelistsBySource[source] = [];
selectedPricelistIds[source] = null;
}
}));
activePricelistsLoadedAt = Date.now();
})();
try {
await activePricelistsLoadPromise;
} finally {
activePricelistsLoadPromise = null;
}
}
function renderPricelistSelectOptions(selectId, source) {
const select = document.getElementById(selectId);
if (!select) return;
const pricelists = activePricelistsBySource[source] || [];
if (pricelists.length === 0) {
select.innerHTML = '<option value="">Нет активных прайслистов</option>';
select.value = '';
return;
}
select.innerHTML = `<option value="">Авто (последний активный)</option>` + pricelists.map(pl => {
return `<option value="${pl.id}">${escapeHtml(pl.version)}</option>`;
}).join('');
const current = selectedPricelistIds[source];
select.value = current ? String(current) : '';
}
function syncPriceSettingsControls() {
renderPricelistSelectOptions('settings-pricelist-estimate', 'estimate');
renderPricelistSelectOptions('settings-pricelist-warehouse', 'warehouse');
renderPricelistSelectOptions('settings-pricelist-competitor', 'competitor');
const disableCheckbox = document.getElementById('settings-disable-price-refresh');
if (disableCheckbox) {
disableCheckbox.checked = disablePriceRefresh;
}
const inStockCheckbox = document.getElementById('settings-only-in-stock');
if (inStockCheckbox) {
inStockCheckbox.checked = onlyInStock;
}
}
function getPricelistVersionById(source, id) {
const pricelists = activePricelistsBySource[source] || [];
const found = pricelists.find(pl => Number(pl.id) === Number(id));
return found ? found.version : null;
}
function renderPricelistSettingsSummary() {
const summary = document.getElementById('pricelist-settings-summary');
if (!summary) return;
const estimate = selectedPricelistIds.estimate ? getPricelistVersionById('estimate', selectedPricelistIds.estimate) || `ID ${selectedPricelistIds.estimate}` : 'авто';
const warehouse = selectedPricelistIds.warehouse ? getPricelistVersionById('warehouse', selectedPricelistIds.warehouse) || `ID ${selectedPricelistIds.warehouse}` : 'авто';
const competitor = selectedPricelistIds.competitor ? getPricelistVersionById('competitor', selectedPricelistIds.competitor) || `ID ${selectedPricelistIds.competitor}` : 'авто';
const refreshState = disablePriceRefresh ? ' | Обновление цен: выкл' : '';
const stockFilterState = onlyInStock ? ' | Только наличие: вкл' : '';
summary.textContent = `Estimate: ${estimate}, Склад: ${warehouse}, Конкуренты: ${competitor}${refreshState}${stockFilterState}`;
}
function updateRefreshPricesButtonState() {
const refreshBtn = document.getElementById('refresh-prices-btn');
if (!refreshBtn) return;
if (disablePriceRefresh) {
refreshBtn.disabled = true;
refreshBtn.className = 'px-4 py-2 bg-gray-300 text-gray-500 rounded cursor-not-allowed';
refreshBtn.title = 'Обновление цен отключено в настройках';
} else {
refreshBtn.disabled = false;
refreshBtn.className = 'px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700';
refreshBtn.title = '';
}
}
function getPriceSettingsStorageKey() {
return `qf_price_settings_${configUUID || 'default'}`;
}
function persistLocalPriceSettings() {
try {
localStorage.setItem(getPriceSettingsStorageKey(), JSON.stringify({
pricelist_ids: selectedPricelistIds,
disable_price_refresh: disablePriceRefresh
}));
} catch (e) {
// ignore localStorage failures
}
}
function restoreLocalPriceSettings() {
try {
const raw = localStorage.getItem(getPriceSettingsStorageKey());
if (!raw) return;
const parsed = JSON.parse(raw);
if (parsed && parsed.pricelist_ids) {
['estimate', 'warehouse', 'competitor'].forEach(source => {
const next = parseInt(parsed.pricelist_ids[source]);
if (Number.isFinite(next) && next > 0) {
selectedPricelistIds[source] = next;
}
});
}
disablePriceRefresh = Boolean(parsed?.disable_price_refresh);
} catch (e) {
// ignore invalid localStorage payload
}
}
function openPriceSettingsModal() {
syncPriceSettingsControls();
renderPricelistSettingsSummary();
document.getElementById('price-settings-modal')?.classList.remove('hidden');
loadActivePricelists().then(() => {
syncPriceSettingsControls();
renderPricelistSettingsSummary();
});
}
function closePriceSettingsModal() {
document.getElementById('price-settings-modal')?.classList.add('hidden');
}
function applyPriceSettings() {
const estimateVal = parseInt(document.getElementById('settings-pricelist-estimate')?.value || '');
const warehouseVal = parseInt(document.getElementById('settings-pricelist-warehouse')?.value || '');
const competitorVal = parseInt(document.getElementById('settings-pricelist-competitor')?.value || '');
const disableVal = Boolean(document.getElementById('settings-disable-price-refresh')?.checked);
const inStockVal = Boolean(document.getElementById('settings-only-in-stock')?.checked);
const prevWarehouseID = currentWarehousePricelistID();
selectedPricelistIds.estimate = Number.isFinite(estimateVal) && estimateVal > 0 ? estimateVal : null;
selectedPricelistIds.warehouse = Number.isFinite(warehouseVal) && warehouseVal > 0 ? warehouseVal : null;
selectedPricelistIds.competitor = Number.isFinite(competitorVal) && competitorVal > 0 ? competitorVal : null;
if (selectedPricelistIds.estimate) {
resolvedAutoPricelistIds.estimate = null;
}
if (selectedPricelistIds.warehouse) {
resolvedAutoPricelistIds.warehouse = null;
}
if (selectedPricelistIds.competitor) {
resolvedAutoPricelistIds.competitor = null;
}
disablePriceRefresh = disableVal;
onlyInStock = inStockVal;
const nextWarehouseID = currentWarehousePricelistID();
if (Number.isFinite(prevWarehouseID) && prevWarehouseID > 0 && prevWarehouseID !== nextWarehouseID) {
warehouseStockLotsByPricelist.delete(prevWarehouseID);
}
if (onlyInStock) {
ensureWarehouseStockFilterLoaded().then(() => {
renderTab();
updateCartUI();
});
}
updateRefreshPricesButtonState();
renderPricelistSettingsSummary();
persistLocalPriceSettings();
closePriceSettingsModal();
schedulePriceLevelsRefresh({ delay: 0, rerender: true, autosave: true });
}
function getCategoryFromLotName(lotName) {
const parts = lotName.split('_');
return parts[0] || '';
}
function getComponentCategory(comp) {
return (comp.category || getCategoryFromLotName(comp.lot_name)).toUpperCase();
}
function getTabForCategory(category) {
const cat = category.toUpperCase();
for (const [tabKey, tabConfig] of Object.entries(TAB_CONFIG)) {
if (tabConfig.categories.map(c => c.toUpperCase()).includes(cat)) {
return tabKey;
}
}
return 'other';
}
function switchTab(tab) {
currentTab = tab;
document.querySelectorAll('.tab-btn').forEach(btn => {
if (btn.dataset.tab === tab) {
btn.classList.remove('border-transparent', 'text-gray-500');
btn.classList.add('border-blue-600', 'text-blue-600');
} else {
btn.classList.add('border-transparent', 'text-gray-500');
btn.classList.remove('border-blue-600', 'text-blue-600');
}
});
hideAutocomplete();
renderTab();
}
function getComponentsForTab(tab) {
const config = TAB_CONFIG[tab];
return allComponents.filter(comp => {
const category = getComponentCategory(comp);
if (tab === 'other') {
return !ASSIGNED_CATEGORIES.includes(category);
}
return config.categories.map(c => c.toUpperCase()).includes(category);
});
}
function getComponentsForCategory(category) {
return allComponents.filter(comp => {
return getComponentCategory(comp) === category.toUpperCase();
});
}
function renderTab() {
const config = TAB_CONFIG[currentTab];
const components = getComponentsForTab(currentTab);
if (config.singleSelect) {
renderSingleSelectTab(config.categories);
} else if (config.sections) {
renderMultiSelectTabWithSections(config.sections);
} else {
renderMultiSelectTab(components);
}
}
function renderSingleSelectTab(categories) {
let html = '';
if (currentTab === 'base') {
html += `
<div class="mb-1 grid grid-cols-1 md:grid-cols-[1fr,16rem] gap-3 items-start">
<label for="server-model-input" class="block text-sm font-medium text-gray-700">Модель сервера для КП:</label>
<label for="support-code-select" class="block text-sm font-medium text-gray-700">Уровень техподдержки:</label>
</div>
<div class="mb-3 grid grid-cols-1 md:grid-cols-[1fr,16rem] gap-3 items-start">
<input type="text"
id="server-model-input"
value="${escapeHtml(serverModelForQuote)}"
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500"
oninput="updateServerModelForQuote(this.value)">
<select id="support-code-select"
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500"
onchange="updateSupportCode(this.value)">
<option value="">—</option>
<option value="1yW" ${supportCode === '1yW' ? 'selected' : ''}>1yW</option>
<option value="1yB" ${supportCode === '1yB' ? 'selected' : ''}>1yB</option>
<option value="1yS" ${supportCode === '1yS' ? 'selected' : ''}>1yS</option>
<option value="1yP" ${supportCode === '1yP' ? 'selected' : ''}>1yP</option>
</select>
</div>
`;
}
html += `
<table class="w-full">
<thead class="bg-gray-50">
<tr>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase w-24">Тип</th>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">LOT</th>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Описание</th>
<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase w-24">Estimate</th>
<th class="px-3 py-2 text-center text-xs font-medium text-gray-500 uppercase w-20">Кол-во</th>
<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase w-28">Стоимость</th>
<th class="px-3 py-2 w-10"></th>
</tr>
</thead>
<tbody class="divide-y">
`;
categories.forEach(cat => {
const catLabel = cat === 'MB' ? 'MB' : cat === 'CPU' ? 'CPU' : cat === 'MEM' ? 'MEM' : cat;
const selectedItem = cart.find(item =>
(item.category || getCategoryFromLotName(item.lot_name)).toUpperCase() === cat.toUpperCase()
);
const comp = selectedItem ? allComponents.find(c => c.lot_name === selectedItem.lot_name) : null;
const price = comp?.current_price || 0;
const estimate = selectedItem?.estimate_price ?? price;
const qty = selectedItem?.quantity || 1;
const total = (selectedItem ? getDisplayPrice(selectedItem) : price) * qty;
html += `
<tr class="hover:bg-gray-50">
<td class="px-3 py-2 text-sm font-medium text-gray-700">${catLabel}</td>
<td class="px-3 py-2">
<div class="autocomplete-wrapper relative">
<input type="text"
id="input-${cat}"
value="${selectedItem?.lot_name || ''}"
placeholder="Введите артикул..."
class="w-full px-2 py-1 border rounded text-sm font-mono"
onfocus="showAutocomplete('${cat}', this)"
oninput="filterAutocomplete('${cat}', this.value)"
onkeydown="handleAutocompleteKey(event, '${cat}')">
</div>
</td>
<td class="px-3 py-2 text-sm text-gray-500 truncate max-w-xs" id="desc-${cat}">${escapeHtml(comp?.description || '')}</td>
<td class="px-3 py-2 text-sm text-right" id="price-${cat}">${formatPriceOrNA(estimate)}</td>
<td class="px-3 py-2 text-center">
<input type="number" min="1" value="${qty}"
id="qty-${cat}"
onchange="updateSingleQuantity('${cat}', this.value)"
class="w-16 px-2 py-1 border rounded text-center text-sm">
</td>
<td class="px-3 py-2 text-sm text-right font-medium" id="total-${cat}">${total ? formatMoney(total) : '—'}</td>
<td class="px-3 py-2 text-center">
${selectedItem ? `
<button onclick="clearSingleSelect('${cat}')" class="text-red-500 hover:text-red-700">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
` : ''}
</td>
</tr>
`;
});
html += '</tbody></table>';
document.getElementById('tab-content').innerHTML = html;
}
function renderMultiSelectTab(components) {
// Get cart items for this tab
const tabItems = cart.filter(item => {
const cat = (item.category || getCategoryFromLotName(item.lot_name)).toUpperCase();
const tab = getTabForCategory(cat);
return tab === currentTab;
});
let html = `
<table class="w-full">
<thead class="bg-gray-50">
<tr>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">LOT</th>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Описание</th>
<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase w-24">Estimate</th>
<th class="px-3 py-2 text-center text-xs font-medium text-gray-500 uppercase w-20">Кол-во</th>
<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase w-28">Стоимость</th>
<th class="px-3 py-2 w-10"></th>
</tr>
</thead>
<tbody class="divide-y">
`;
// Render existing cart items for this tab
tabItems.forEach((item, idx) => {
const comp = allComponents.find(c => c.lot_name === item.lot_name);
const total = getDisplayPrice(item) * item.quantity;
html += `
<tr class="hover:bg-gray-50">
<td class="px-3 py-2 text-sm font-mono">${escapeHtml(item.lot_name)}</td>
<td class="px-3 py-2 text-sm text-gray-500 truncate max-w-xs">${escapeHtml(item.description || comp?.description || '')}</td>
<td class="px-3 py-2 text-sm text-right">${formatPriceOrNA(item.estimate_price ?? item.unit_price)}</td>
<td class="px-3 py-2 text-center">
<input type="number" min="1" value="${item.quantity}"
onchange="updateMultiQuantity('${item.lot_name}', this.value)"
class="w-16 px-2 py-1 border rounded text-center text-sm">
</td>
<td class="px-3 py-2 text-sm text-right font-medium">${formatMoney(total)}</td>
<td class="px-3 py-2 text-center">
<button onclick="removeFromCart('${item.lot_name}')" class="text-red-500 hover:text-red-700">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</td>
</tr>
`;
});
// Add empty row for new item
html += `
<tr class="hover:bg-gray-50 bg-gray-50">
<td class="px-3 py-2" colspan="2">
<div class="autocomplete-wrapper relative">
<input type="text"
id="input-new-item"
placeholder="Добавить компонент..."
class="w-full px-2 py-1 border rounded text-sm"
onfocus="showAutocompleteMulti(this)"
oninput="filterAutocompleteMulti(this.value)"
onkeydown="handleAutocompleteKeyMulti(event)">
</div>
</td>
<td class="px-3 py-2 text-sm text-right text-gray-400" id="new-price">N/A</td>
<td class="px-3 py-2 text-center">
<input type="number" min="1" value="1" id="new-qty"
class="w-16 px-2 py-1 border rounded text-center text-sm">
</td>
<td class="px-3 py-2 text-sm text-right text-gray-400" id="new-total">—</td>
<td class="px-3 py-2"></td>
</tr>
`;
html += '</tbody></table>';
html += `<p class="text-center text-sm text-gray-500 mt-4">Доступно компонентов: ${components.length}</p>`;
document.getElementById('tab-content').innerHTML = html;
}
function renderMultiSelectTabWithSections(sections) {
// Get cart items for this tab
const tabItems = cart.filter(item => {
const cat = (item.category || getCategoryFromLotName(item.lot_name)).toUpperCase();
const tab = getTabForCategory(cat);
return tab === currentTab;
});
let html = '';
let totalComponents = 0;
sections.forEach((section, sectionIdx) => {
// Get components for this section's categories
const sectionCategories = section.categories.map(c => c.toUpperCase());
const sectionComponents = allComponents.filter(comp => {
const category = getComponentCategory(comp);
return sectionCategories.includes(category);
});
totalComponents += sectionComponents.length;
// Get cart items for this section
const sectionItems = tabItems.filter(item => {
const cat = (item.category || getCategoryFromLotName(item.lot_name)).toUpperCase();
return sectionCategories.includes(cat);
});
// Section header
html += `
<div class="mb-6 ${sectionIdx > 0 ? 'mt-6' : ''}">
<h3 class="text-sm font-semibold text-gray-700 mb-3 px-3">${section.title}</h3>
<table class="w-full">
<thead class="bg-gray-50">
<tr>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">LOT</th>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Описание</th>
<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase w-24">Estimate</th>
<th class="px-3 py-2 text-center text-xs font-medium text-gray-500 uppercase w-20">Кол-во</th>
<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase w-28">Стоимость</th>
<th class="px-3 py-2 w-10"></th>
</tr>
</thead>
<tbody class="divide-y">
`;
// Render existing cart items for this section
sectionItems.forEach((item) => {
const comp = allComponents.find(c => c.lot_name === item.lot_name);
const total = getDisplayPrice(item) * item.quantity;
html += `
<tr class="hover:bg-gray-50">
<td class="px-3 py-2 text-sm font-mono">${escapeHtml(item.lot_name)}</td>
<td class="px-3 py-2 text-sm text-gray-500 truncate max-w-xs">${escapeHtml(item.description || comp?.description || '')}</td>
<td class="px-3 py-2 text-sm text-right">${formatPriceOrNA(item.estimate_price ?? item.unit_price)}</td>
<td class="px-3 py-2 text-center">
<input type="number" min="1" value="${item.quantity}"
onchange="updateMultiQuantity('${item.lot_name}', this.value)"
class="w-16 px-2 py-1 border rounded text-center text-sm">
</td>
<td class="px-3 py-2 text-sm text-right font-medium">${formatMoney(total)}</td>
<td class="px-3 py-2 text-center">
<button onclick="removeFromCart('${item.lot_name}')" class="text-red-500 hover:text-red-700">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</td>
</tr>
`;
});
// Add empty row for new item in this section
const sectionId = section.categories.join('-');
const categoriesStr = section.categories.join(',');
html += `
<tr class="hover:bg-gray-50 bg-gray-50">
<td class="px-3 py-2" colspan="2">
<div class="autocomplete-wrapper relative">
<input type="text"
id="input-section-${sectionId}"
data-categories="${categoriesStr}"
placeholder="Добавить ${section.title.toLowerCase()}..."
class="w-full px-2 py-1 border rounded text-sm"
onfocus="showAutocompleteSection('${sectionId}', this)"
oninput="filterAutocompleteSection('${sectionId}', this.value, this)"
onkeydown="handleAutocompleteKeySection(event, '${sectionId}')">
</div>
</td>
<td class="px-3 py-2 text-sm text-right text-gray-400" id="new-price-${sectionId}">N/A</td>
<td class="px-3 py-2 text-center">
<input type="number" min="1" value="1" id="new-qty-${sectionId}"
class="w-16 px-2 py-1 border rounded text-center text-sm">
</td>
<td class="px-3 py-2 text-sm text-right text-gray-400" id="new-total-${sectionId}">—</td>
<td class="px-3 py-2"></td>
</tr>
`;
html += `
</tbody>
</table>
<p class="text-center text-sm text-gray-500 mt-2">Доступно: ${sectionComponents.length}</p>
</div>
`;
});
document.getElementById('tab-content').innerHTML = html;
}
// Load prices for components in a category/tab via API
async function ensurePricesLoaded(components) {
if (!components || components.length === 0) return;
// Filter out components that already have prices cached
const toLoad = components.filter(c => !(c.lot_name in componentPricesCache));
if (toLoad.length === 0) return;
try {
// Use quote/price-levels API to get prices for these components
const resp = await fetch('/api/quote/price-levels', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
items: toLoad.map(c => ({ lot_name: c.lot_name, quantity: 1 })),
pricelist_ids: Object.fromEntries(
Object.entries(selectedPricelistIds)
.filter(([, id]) => typeof id === 'number' && id > 0)
)
})
});
if (resp.ok) {
const data = await resp.json();
if (data.items) {
data.items.forEach(item => {
// Cache the estimate price (or 0 if not found)
componentPricesCache[item.lot_name] = item.estimate_price || 0;
});
}
}
} catch (e) {
console.error('Failed to load component prices', e);
}
}
function hasComponentPrice(lotName) {
return lotName in componentPricesCache && componentPricesCache[lotName] > 0;
}
// Autocomplete for single select (Base tab)
async function showAutocomplete(category, input) {
autocompleteInput = input;
autocompleteCategory = category;
autocompleteMode = 'single';
autocompleteIndex = -1;
const components = getComponentsForCategory(category);
await ensurePricesLoaded(components);
filterAutocomplete(category, input.value);
}
function filterAutocomplete(category, search) {
const components = getComponentsForCategory(category);
const searchLower = search.toLowerCase();
autocompleteFiltered = components.filter(c => {
if (!hasComponentPrice(c.lot_name)) return false;
if (!isComponentAllowedByStockFilter(c)) return false;
const text = (c.lot_name + ' ' + (c.description || '')).toLowerCase();
return text.includes(searchLower);
})
.sort((a, b) => {
// Sort by popularity_score desc, then by lot_name
const popDiff = (b.popularity_score || 0) - (a.popularity_score || 0);
if (popDiff !== 0) return popDiff;
return a.lot_name.localeCompare(b.lot_name);
});
renderAutocomplete();
}
function renderAutocomplete() {
const dropdown = document.getElementById('autocomplete-dropdown');
if (autocompleteFiltered.length === 0 || !autocompleteInput) {
dropdown.classList.add('hidden');
return;
}
const rect = autocompleteInput.getBoundingClientRect();
dropdown.style.top = (rect.bottom + window.scrollY) + 'px';
dropdown.style.left = rect.left + 'px';
dropdown.style.width = Math.max(rect.width, 400) + 'px';
// Build autocomplete items based on mode
dropdown.innerHTML = autocompleteFiltered.map((comp, idx) => {
let onmousedown;
if (autocompleteMode === 'section') {
onmousedown = `selectAutocompleteItemSection(${idx}, '${autocompleteCategory}')`;
} else if (autocompleteMode === 'multi') {
onmousedown = `selectAutocompleteItemMulti(${idx})`;
} else {
// single mode
onmousedown = `selectAutocompleteItem(${idx})`;
}
return `
<div class="autocomplete-item ${idx === autocompleteIndex ? 'selected' : ''}"
onmousedown="${onmousedown}">
<div class="font-mono text-sm">${escapeHtml(comp.lot_name)}</div>
<div class="text-xs text-gray-500 truncate">${escapeHtml(comp.description || '')}</div>
</div>
`;
}).join('');
dropdown.classList.remove('hidden');
}
function handleAutocompleteKey(event, category) {
if (event.key === 'ArrowDown') {
event.preventDefault();
autocompleteIndex = Math.min(autocompleteIndex + 1, autocompleteFiltered.length - 1);
renderAutocomplete();
} else if (event.key === 'ArrowUp') {
event.preventDefault();
autocompleteIndex = Math.max(autocompleteIndex - 1, -1);
renderAutocomplete();
} else if (event.key === 'Enter') {
event.preventDefault();
if (autocompleteIndex >= 0 && autocompleteIndex < autocompleteFiltered.length) {
selectAutocompleteItem(autocompleteIndex);
}
} else if (event.key === 'Escape') {
hideAutocomplete();
}
}
function selectAutocompleteItem(index) {
const comp = autocompleteFiltered[index];
if (!comp || !autocompleteCategory) return;
// Remove existing item of this category
cart = cart.filter(item =>
(item.category || getCategoryFromLotName(item.lot_name)).toUpperCase() !== autocompleteCategory.toUpperCase()
);
const qtyInput = document.getElementById('qty-' + autocompleteCategory);
const qty = parseInt(qtyInput?.value) || 1;
const price = componentPricesCache[comp.lot_name] || 0;
cart.push({
lot_name: comp.lot_name,
quantity: qty,
unit_price: price,
estimate_price: price,
warehouse_price: null,
competitor_price: null,
delta_wh_estimate_abs: null,
delta_wh_estimate_pct: null,
delta_comp_estimate_abs: null,
delta_comp_estimate_pct: null,
delta_comp_wh_abs: null,
delta_comp_wh_pct: null,
price_missing: ['warehouse', 'competitor'],
description: comp.description || '',
category: getComponentCategory(comp)
});
hideAutocomplete();
renderTab();
updateCartUI();
triggerAutoSave();
schedulePriceLevelsRefresh({ delay: 80, rerender: true, autosave: false });
}
function hideAutocomplete() {
document.getElementById('autocomplete-dropdown').classList.add('hidden');
autocompleteInput = null;
autocompleteCategory = null;
autocompleteMode = null;
autocompleteIndex = -1;
}
// Autocomplete for multi select tabs
async function showAutocompleteMulti(input) {
autocompleteInput = input;
autocompleteCategory = null;
autocompleteMode = 'multi';
autocompleteIndex = -1;
const components = getComponentsForTab(currentTab);
await ensurePricesLoaded(components);
filterAutocompleteMulti(input.value);
}
function filterAutocompleteMulti(search) {
const components = getComponentsForTab(currentTab);
const searchLower = search.toLowerCase();
// Filter out already added items
const addedLots = new Set(cart.map(i => i.lot_name));
autocompleteFiltered = components.filter(c => {
if (!hasComponentPrice(c.lot_name)) return false;
if (addedLots.has(c.lot_name)) return false;
if (!isComponentAllowedByStockFilter(c)) return false;
const text = (c.lot_name + ' ' + (c.description || '')).toLowerCase();
return text.includes(searchLower);
})
.sort((a, b) => {
// Sort by popularity_score desc, then by lot_name
const popDiff = (b.popularity_score || 0) - (a.popularity_score || 0);
if (popDiff !== 0) return popDiff;
return a.lot_name.localeCompare(b.lot_name);
});
renderAutocomplete();
}
function handleAutocompleteKeyMulti(event) {
if (event.key === 'ArrowDown') {
event.preventDefault();
autocompleteIndex = Math.min(autocompleteIndex + 1, autocompleteFiltered.length - 1);
renderAutocomplete();
} else if (event.key === 'ArrowUp') {
event.preventDefault();
autocompleteIndex = Math.max(autocompleteIndex - 1, -1);
renderAutocomplete();
} else if (event.key === 'Enter') {
event.preventDefault();
if (autocompleteIndex >= 0 && autocompleteIndex < autocompleteFiltered.length) {
selectAutocompleteItemMulti(autocompleteIndex);
}
} else if (event.key === 'Escape') {
hideAutocomplete();
}
}
function selectAutocompleteItemMulti(index) {
const comp = autocompleteFiltered[index];
if (!comp) return;
const qtyInput = document.getElementById('new-qty');
const qty = parseInt(qtyInput?.value) || 1;
const price = componentPricesCache[comp.lot_name] || 0;
cart.push({
lot_name: comp.lot_name,
quantity: qty,
unit_price: price,
estimate_price: price,
warehouse_price: null,
competitor_price: null,
delta_wh_estimate_abs: null,
delta_wh_estimate_pct: null,
delta_comp_estimate_abs: null,
delta_comp_estimate_pct: null,
delta_comp_wh_abs: null,
delta_comp_wh_pct: null,
price_missing: ['warehouse', 'competitor'],
description: comp.description || '',
category: getComponentCategory(comp)
});
hideAutocomplete();
renderTab();
updateCartUI();
triggerAutoSave();
schedulePriceLevelsRefresh({ delay: 80, rerender: true, autosave: false });
}
// Autocomplete for sectioned tabs (like storage with RAID and Disks sections)
async function showAutocompleteSection(sectionId, input) {
autocompleteInput = input;
autocompleteCategory = sectionId; // Store section ID
autocompleteMode = 'section';
autocompleteIndex = -1;
// Load prices for tab components
const components = getComponentsForTab(currentTab);
await ensurePricesLoaded(components);
filterAutocompleteSection(sectionId, input.value, input);
}
function filterAutocompleteSection(sectionId, search, inputElement) {
const searchLower = search.toLowerCase();
// Get categories from input element's data attribute
const categoriesStr = inputElement && inputElement.dataset ? inputElement.dataset.categories : '';
if (!categoriesStr) {
autocompleteFiltered = [];
renderAutocomplete();
return;
}
const categoryList = categoriesStr.split(',').map(c => c.trim().toUpperCase());
// Get components for this section's categories
const sectionComponents = allComponents.filter(comp => {
const category = getComponentCategory(comp);
return categoryList.includes(category);
});
// Filter out already added items
const addedLots = new Set(cart.map(i => i.lot_name));
autocompleteFiltered = sectionComponents.filter(c => {
if (!hasComponentPrice(c.lot_name)) return false;
if (addedLots.has(c.lot_name)) return false;
if (!isComponentAllowedByStockFilter(c)) return false;
const text = (c.lot_name + ' ' + (c.description || '')).toLowerCase();
return text.includes(searchLower);
})
.sort((a, b) => {
// Sort by popularity_score desc, then by lot_name
const popDiff = (b.popularity_score || 0) - (a.popularity_score || 0);
if (popDiff !== 0) return popDiff;
return a.lot_name.localeCompare(b.lot_name);
});
renderAutocomplete();
}
function handleAutocompleteKeySection(event, sectionId) {
if (event.key === 'ArrowDown') {
event.preventDefault();
autocompleteIndex = Math.min(autocompleteIndex + 1, autocompleteFiltered.length - 1);
renderAutocomplete();
} else if (event.key === 'ArrowUp') {
event.preventDefault();
autocompleteIndex = Math.max(autocompleteIndex - 1, -1);
renderAutocomplete();
} else if (event.key === 'Enter') {
event.preventDefault();
if (autocompleteIndex >= 0 && autocompleteIndex < autocompleteFiltered.length) {
selectAutocompleteItemSection(autocompleteIndex, sectionId);
}
} else if (event.key === 'Escape') {
hideAutocomplete();
}
}
function selectAutocompleteItemSection(index, sectionId) {
const comp = autocompleteFiltered[index];
if (!comp) return;
const qtyInput = document.getElementById('new-qty-' + sectionId);
const qty = parseInt(qtyInput?.value) || 1;
const price = componentPricesCache[comp.lot_name] || 0;
cart.push({
lot_name: comp.lot_name,
quantity: qty,
unit_price: price,
estimate_price: price,
warehouse_price: null,
competitor_price: null,
delta_wh_estimate_abs: null,
delta_wh_estimate_pct: null,
delta_comp_estimate_abs: null,
delta_comp_estimate_pct: null,
delta_comp_wh_abs: null,
delta_comp_wh_pct: null,
price_missing: ['warehouse', 'competitor'],
description: comp.description || '',
category: getComponentCategory(comp)
});
hideAutocomplete();
// Clear the input field
const input = document.getElementById('input-section-' + sectionId);
if (input) input.value = '';
// Reset quantity to 1
if (qtyInput) qtyInput.value = '1';
renderTab();
updateCartUI();
triggerAutoSave();
schedulePriceLevelsRefresh({ delay: 80, rerender: true, autosave: false });
}
function clearSingleSelect(category) {
cart = cart.filter(item =>
(item.category || getCategoryFromLotName(item.lot_name)).toUpperCase() !== category.toUpperCase()
);
renderTab();
updateCartUI();
triggerAutoSave();
}
function updateSingleQuantity(category, value) {
const qty = parseInt(value) || 1;
const item = cart.find(i =>
(i.category || getCategoryFromLotName(i.lot_name)).toUpperCase() === category.toUpperCase()
);
if (item) {
item.quantity = Math.max(1, qty);
renderTab();
updateCartUI();
triggerAutoSave();
}
}
function updateMultiQuantity(lotName, value) {
const qty = parseInt(value) || 1;
const item = cart.find(i => i.lot_name === lotName);
if (item) {
item.quantity = Math.max(1, qty);
updateCartUI();
triggerAutoSave();
// Update total in the row
const row = document.querySelector(`input[onchange*="${lotName}"]`)?.closest('tr');
if (row) {
const totalCell = row.querySelector('td:nth-child(5)');
if (totalCell) {
totalCell.textContent = formatMoney(getDisplayPrice(item) * item.quantity);
}
}
}
}
function removeFromCart(lotName) {
cart = cart.filter(i => i.lot_name !== lotName);
renderTab();
updateCartUI();
triggerAutoSave();
}
function updateCartUI() {
window._currentCart = cart; // expose for BOM/Pricing tabs
const total = cart.reduce((sum, item) => sum + (getDisplayPrice(item) * item.quantity), 0);
document.getElementById('cart-total').textContent = formatMoney(total);
// Recalculate custom price section if active
calculateCustomPrice();
renderSalePriceTable();
scheduleArticlePreview();
if (cart.length === 0) {
document.getElementById('cart-items').innerHTML =
'<div class="text-gray-500 text-center py-2">Конфигурация пуста</div>';
return;
}
// Sort cart items by category display order
const sortedCart = [...cart].sort((a, b) => {
const catA = (a.category || getCategoryFromLotName(a.lot_name)).toUpperCase();
const catB = (b.category || getCategoryFromLotName(b.lot_name)).toUpperCase();
const orderA = categoryOrderMap[catA] || 9999;
const orderB = categoryOrderMap[catB] || 9999;
return orderA - orderB;
});
const grouped = {};
sortedCart.forEach(item => {
const cat = item.category || getCategoryFromLotName(item.lot_name);
const tab = getTabForCategory(cat);
if (!grouped[tab]) grouped[tab] = [];
grouped[tab].push(item);
});
// Sort tabs by minimum display order of their categories
const sortedTabs = Object.entries(grouped).sort((a, b) => {
const minOrderA = Math.min(...a[1].map(item => {
const cat = (item.category || getCategoryFromLotName(item.lot_name)).toUpperCase();
return categoryOrderMap[cat] || 9999;
}));
const minOrderB = Math.min(...b[1].map(item => {
const cat = (item.category || getCategoryFromLotName(item.lot_name)).toUpperCase();
return categoryOrderMap[cat] || 9999;
}));
return minOrderA - minOrderB;
});
let html = '';
for (const [tab, items] of sortedTabs) {
const tabLabel = TAB_CONFIG[tab]?.label || tab;
html += `<div class="mb-2"><div class="text-xs font-medium text-gray-400 uppercase mb-1">${tabLabel}</div>`;
items.forEach(item => {
const itemTotal = getDisplayPrice(item) * item.quantity;
html += `
<div class="flex justify-between items-center py-1 text-sm">
<div class="flex-1">
<span class="font-mono">${escapeHtml(item.lot_name)}</span>
<span class="text-gray-400 mx-1">×</span>
<span>${item.quantity}</span>
</div>
<div class="flex items-center space-x-2">
<span>${formatMoney(itemTotal)}</span>
<button onclick="removeFromCart('${item.lot_name}')" class="text-red-500 hover:text-red-700">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
</div>
`;
});
html += '</div>';
}
document.getElementById('cart-items').innerHTML = html;
}
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function updateServerModelForQuote(value) {
serverModelForQuote = value || '';
scheduleArticlePreview();
}
function updateSupportCode(value) {
supportCode = value || '';
scheduleArticlePreview();
}
function scheduleArticlePreview() {
if (articlePreviewTimeout) {
clearTimeout(articlePreviewTimeout);
}
articlePreviewTimeout = setTimeout(() => {
previewArticle();
}, 250);
}
async function previewArticle() {
const el = document.getElementById('article-display');
if (!el) return;
const model = serverModelForQuote.trim();
const estimatePricelistID = getEffectivePricelistID('estimate');
if (!model || !estimatePricelistID || cart.length === 0) {
currentArticle = '';
el.textContent = 'Артикул: —';
return;
}
try {
const resp = await fetch('/api/configs/preview-article', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
server_model: serverModelForQuote,
support_code: supportCode,
pricelist_id: estimatePricelistID,
items: cart.map(item => ({
lot_name: item.lot_name,
quantity: item.quantity,
unit_price: item.unit_price || 0
}))
})
});
if (!resp.ok) {
currentArticle = '';
el.textContent = 'Артикул: —';
return;
}
const data = await resp.json();
currentArticle = data.article || '';
el.textContent = currentArticle ? ('Артикул: ' + currentArticle) : 'Артикул: —';
} catch(e) {
currentArticle = '';
el.textContent = 'Артикул: —';
}
}
function getCurrentArticle() {
return currentArticle || '';
}
function getAutosaveStorageKey() {
return `qf_config_autosave_${configUUID || 'default'}`;
}
function buildSavePayload() {
const customPriceInput = document.getElementById('custom-price-input');
const customPriceValue = parseFloat(customPriceInput.value);
const customPrice = customPriceValue > 0 ? customPriceValue : null;
return {
name: configName,
items: cart,
custom_price: customPrice,
notes: '',
server_count: serverCount,
server_model: serverModelForQuote,
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
};
}
function persistAutosaveDraft() {
if (!configUUID) return;
try {
sessionStorage.setItem(getAutosaveStorageKey(), JSON.stringify({
payload: buildSavePayload(),
saved_at: Date.now()
}));
} catch (_) {
// ignore storage failures
}
}
function clearAutosaveDraft() {
try {
sessionStorage.removeItem(getAutosaveStorageKey());
} catch (_) {
// ignore storage failures
}
}
function restoreAutosaveDraftIfAny() {
if (!configUUID) return;
let raw = null;
try {
raw = sessionStorage.getItem(getAutosaveStorageKey());
} catch (_) {
raw = null;
}
if (!raw) return;
try {
const parsed = JSON.parse(raw);
const payload = parsed && parsed.payload ? parsed.payload : null;
if (!payload) return;
if (Array.isArray(payload.items)) {
cart = payload.items.map(item => ({
lot_name: item.lot_name,
quantity: item.quantity,
unit_price: item.unit_price,
estimate_price: item.unit_price,
warehouse_price: null,
competitor_price: null,
description: item.description || '',
category: item.category || getCategoryFromLotName(item.lot_name)
}));
}
if (typeof payload.server_count === 'number' && payload.server_count > 0) {
serverCount = payload.server_count;
const serverCountInput = document.getElementById('server-count');
if (serverCountInput) serverCountInput.value = serverCount;
const totalServerCount = document.getElementById('total-server-count');
if (totalServerCount) totalServerCount.textContent = serverCount;
}
serverModelForQuote = payload.server_model || serverModelForQuote;
supportCode = payload.support_code || supportCode;
currentArticle = payload.article || currentArticle;
selectedPricelistIds.estimate = payload.pricelist_id || selectedPricelistIds.estimate;
onlyInStock = Boolean(payload.only_in_stock);
const customPriceInput = document.getElementById('custom-price-input');
if (customPriceInput) {
if (typeof payload.custom_price === 'number' && payload.custom_price > 0) {
customPriceInput.value = payload.custom_price.toFixed(2);
} else {
customPriceInput.value = '';
}
}
hasUnsavedChanges = true;
} catch (_) {
// ignore invalid draft
}
}
function saveConfigOnExit() {
if (!configUUID || !hasUnsavedChanges || exitSaveStarted) return;
exitSaveStarted = true;
try {
fetch('/api/configs/' + configUUID, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(buildSavePayload()),
keepalive: true
});
} catch (_) {
// best effort save on page exit
}
}
function triggerAutoSave() {
// Autosave keeps local draft only; server revision is created on Save/Exit.
hasUnsavedChanges = true;
if (autoSaveTimeout) {
clearTimeout(autoSaveTimeout);
}
autoSaveTimeout = setTimeout(() => {
persistAutosaveDraft();
}, 1000);
}
async function saveConfig(showNotification = true) {
// RBAC disabled - no token check required
if (!configUUID) return;
if (priceLevelsRefreshTimer) {
clearTimeout(priceLevelsRefreshTimer);
priceLevelsRefreshTimer = null;
}
await refreshPriceLevels({ force: true, noCache: true });
try {
const resp = await fetch('/api/configs/' + configUUID, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(buildSavePayload())
});
if (!resp.ok) {
if (showNotification) {
showToast('Ошибка сохранения', 'error');
}
return;
}
const saved = await resp.json();
if (saved && saved.current_version_no) {
currentVersionNo = saved.current_version_no;
const versionEl = document.getElementById('breadcrumb-config-version');
if (versionEl) versionEl.textContent = 'v' + currentVersionNo;
}
hasUnsavedChanges = false;
clearAutosaveDraft();
exitSaveStarted = false;
if (showNotification) {
showToast('Сохранено', 'success');
}
} catch(e) {
if (showNotification) {
showToast('Ошибка сохранения', 'error');
}
}
}
// Helper function to extract filename from Content-Disposition header
function getFilenameFromResponse(resp) {
const contentDisposition = resp.headers.get('content-disposition');
if (!contentDisposition) return null;
const matches = contentDisposition.match(/filename="?([^"]+)"?/);
return matches && matches[1] ? matches[1] : null;
}
async function exportCSV() {
if (cart.length === 0) return;
try {
if (priceLevelsRefreshTimer) {
clearTimeout(priceLevelsRefreshTimer);
priceLevelsRefreshTimer = null;
}
await refreshPriceLevels({ force: true, noCache: true });
const exportItems = cart.map(item => ({
...item,
unit_price: getDisplayPrice(item),
}));
const article = getCurrentArticle();
const resp = await fetch('/api/export/csv', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({items: exportItems, name: configName, project_uuid: projectUUID, article: article, server_count: serverCount, pricelist_id: selectedPricelistIds.estimate || null})
});
const blob = await resp.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
const articleForName = article || 'BOM';
a.download = getFilenameFromResponse(resp) || ((configName || 'config') + ' ' + articleForName + '.csv');
a.click();
window.URL.revokeObjectURL(url);
} catch(e) {
showToast('Ошибка экспорта', 'error');
}
}
function formatLineTotalTooltip(qty, unitPrice) {
if (typeof unitPrice !== 'number' || unitPrice <= 0) return '';
const lineTotal = qty * unitPrice;
return `${formatNumberRu(qty)} * ${formatMoney(unitPrice)} = ${formatMoney(lineTotal)}`;
}
function toggleSection(contentId, iconId) {
const content = document.getElementById(contentId);
const icon = document.getElementById(iconId);
if (!content || !icon) return;
const isHidden = content.classList.toggle('hidden');
if (isHidden) {
icon.classList.add('-rotate-90');
} else {
icon.classList.remove('-rotate-90');
}
}
function toggleCartSummarySection() {
toggleSection('cart-summary-content', 'cart-summary-toggle-icon');
}
function toggleCustomPriceSection() {
toggleSection('custom-price-content', 'custom-price-toggle-icon');
}
function toggleSalePriceSection() {
toggleSection('sale-price-content', 'sale-price-toggle-icon');
}
function formatDiffPercent(baseTotal, compareTotal, compareLabel) {
if (typeof baseTotal !== 'number' || typeof compareTotal !== 'number' || compareTotal <= 0) {
return `N/A от ${compareLabel}`;
}
const pct = ((baseTotal - compareTotal) / compareTotal) * 100;
const sign = pct > 0 ? '+' : '';
return `${sign}${pct.toFixed(1)}% от ${compareLabel}`;
}
function getTotalClass(current, references) {
const validRefs = references.filter(v => typeof v === 'number' && v > 0);
if (typeof current !== 'number' || current <= 0 || validRefs.length === 0) {
return 'text-gray-900';
}
const avg = validRefs.reduce((sum, v) => sum + v, 0) / validRefs.length;
if (current < avg) return 'text-green-600';
if (current > avg) return 'text-red-600';
return 'text-gray-900';
}
function renderSalePriceTable() {
const body = document.getElementById('sale-prices-body');
const totalEstEl = document.getElementById('sale-total-est');
const totalWarehouseEl = document.getElementById('sale-total-warehouse');
const totalCompetitorEl = document.getElementById('sale-total-competitor');
if (!body || !totalEstEl || !totalWarehouseEl || !totalCompetitorEl) return;
if (cart.length === 0) {
body.innerHTML = '<tr><td colspan="5" class="px-3 py-3 text-center text-gray-500">Конфигурация пуста</td></tr>';
totalEstEl.textContent = '$0.00';
totalWarehouseEl.textContent = '$0.00';
totalCompetitorEl.textContent = '$0.00';
totalEstEl.title = '';
totalWarehouseEl.title = '';
totalCompetitorEl.title = '';
totalEstEl.className = 'px-3 py-2 text-right';
totalWarehouseEl.className = 'px-3 py-2 text-right';
totalCompetitorEl.className = 'px-3 py-2 text-right';
return;
}
const sortedCart = [...cart].sort((a, b) => {
const catA = (a.category || getCategoryFromLotName(a.lot_name)).toUpperCase();
const catB = (b.category || getCategoryFromLotName(b.lot_name)).toUpperCase();
const orderA = categoryOrderMap[catA] || 9999;
const orderB = categoryOrderMap[catB] || 9999;
return orderA - orderB;
});
let html = '';
let totalEstimate = 0;
let totalWarehouse = 0;
let totalCompetitor = 0;
sortedCart.forEach(item => {
const qty = item.quantity || 1;
const estPrice = item.estimate_price;
const warehousePrice = item.warehouse_price;
const competitorPrice = item.competitor_price;
if (typeof estPrice === 'number' && estPrice > 0) totalEstimate += estPrice * qty;
if (typeof warehousePrice === 'number' && warehousePrice > 0) totalWarehouse += warehousePrice * qty;
if (typeof competitorPrice === 'number' && competitorPrice > 0) totalCompetitor += competitorPrice * qty;
const estTooltip = formatLineTotalTooltip(qty, estPrice);
const warehouseTooltip = formatLineTotalTooltip(qty, warehousePrice);
const competitorTooltip = formatLineTotalTooltip(qty, competitorPrice);
html += `
<tr>
<td class="px-3 py-2 font-mono">${escapeHtml(item.lot_name)}</td>
<td class="px-3 py-2 text-right">${qty}</td>
<td class="px-3 py-2 text-right" title="${escapeHtml(estTooltip)}">${formatPriceOrNA(estPrice)}</td>
<td class="px-3 py-2 text-right" title="${escapeHtml(warehouseTooltip)}">${formatPriceOrNA(warehousePrice)}</td>
<td class="px-3 py-2 text-right" title="${escapeHtml(competitorTooltip)}">${formatPriceOrNA(competitorPrice)}</td>
</tr>
`;
});
body.innerHTML = html;
totalEstEl.textContent = formatMoney(totalEstimate);
totalWarehouseEl.textContent = formatMoney(totalWarehouse);
totalCompetitorEl.textContent = formatMoney(totalCompetitor);
totalEstEl.title =
`${formatDiffPercent(totalEstimate, totalWarehouse, 'склад')}\n` +
`${formatDiffPercent(totalEstimate, totalCompetitor, 'конкуренты')}`;
totalWarehouseEl.title =
`${formatDiffPercent(totalWarehouse, totalEstimate, 'est.price')}\n` +
`${formatDiffPercent(totalWarehouse, totalCompetitor, 'конкуренты')}`;
totalCompetitorEl.title =
`${formatDiffPercent(totalCompetitor, totalEstimate, 'est.price')}\n` +
`${formatDiffPercent(totalCompetitor, totalWarehouse, 'склад')}`;
totalEstEl.className = `px-3 py-2 text-right ${getTotalClass(totalEstimate, [totalWarehouse, totalCompetitor])}`;
totalWarehouseEl.className = `px-3 py-2 text-right ${getTotalClass(totalWarehouse, [totalEstimate, totalCompetitor])}`;
totalCompetitorEl.className = `px-3 py-2 text-right ${getTotalClass(totalCompetitor, [totalEstimate, totalWarehouse])}`;
}
// Custom price functionality
function calculateCustomPrice() {
const customPriceInput = document.getElementById('custom-price-input');
const adjustedPricesEl = document.getElementById('adjusted-prices');
const discountInfoEl = document.getElementById('discount-info');
const discountPercentEl = document.getElementById('discount-percent');
const adjustedPricesBodyEl = document.getElementById('adjusted-prices-body');
const adjustedTotalOriginalEl = document.getElementById('adjusted-total-original');
const adjustedTotalNewEl = document.getElementById('adjusted-total-new');
const adjustedTotalFinalEl = document.getElementById('adjusted-total-final');
if (!customPriceInput || !adjustedPricesEl || !discountInfoEl || !discountPercentEl ||
!adjustedPricesBodyEl || !adjustedTotalOriginalEl || !adjustedTotalNewEl || !adjustedTotalFinalEl) {
return;
}
const customPrice = parseFloat(customPriceInput.value) || 0;
const originalTotal = cart.reduce((sum, item) => sum + (getDisplayPrice(item) * item.quantity), 0);
if (customPrice <= 0 || cart.length === 0 || originalTotal <= 0) {
adjustedPricesEl.classList.add('hidden');
discountInfoEl.classList.add('hidden');
return;
}
// Calculate discount percentage
const discountPercent = ((originalTotal - customPrice) / originalTotal) * 100;
const coefficient = customPrice / originalTotal;
// Show discount info
discountInfoEl.classList.remove('hidden');
discountPercentEl.textContent = discountPercent.toFixed(1) + '%';
// Update discount color based on value
const discountEl = discountPercentEl;
if (discountPercent > 0) {
discountEl.className = 'text-2xl font-bold text-green-600';
} else if (discountPercent < 0) {
discountEl.className = 'text-2xl font-bold text-red-600';
} else {
discountEl.className = 'text-2xl font-bold text-gray-600';
}
// Build adjusted prices table
// Sort cart items by category display order
const sortedCart = [...cart].sort((a, b) => {
const catA = (a.category || getCategoryFromLotName(a.lot_name)).toUpperCase();
const catB = (b.category || getCategoryFromLotName(b.lot_name)).toUpperCase();
const orderA = categoryOrderMap[catA] || 9999;
const orderB = categoryOrderMap[catB] || 9999;
return orderA - orderB;
});
let html = '';
let totalOriginal = 0;
let totalNew = 0;
sortedCart.forEach(item => {
const originalPrice = getDisplayPrice(item);
const newPrice = originalPrice * coefficient;
const itemOriginalTotal = originalPrice * item.quantity;
const itemNewTotal = newPrice * item.quantity;
totalOriginal += itemOriginalTotal;
totalNew += itemNewTotal;
html += `
<tr>
<td class="px-3 py-2 font-mono">${escapeHtml(item.lot_name)}</td>
<td class="px-3 py-2 text-right">${item.quantity}</td>
<td class="px-3 py-2 text-right text-gray-500">${formatMoney(originalPrice)}</td>
<td class="px-3 py-2 text-right text-green-600">${formatMoney(newPrice)}</td>
<td class="px-3 py-2 text-right">${formatMoney(itemNewTotal)}</td>
</tr>
`;
});
adjustedPricesBodyEl.innerHTML = html;
adjustedTotalOriginalEl.textContent = formatMoney(totalOriginal);
adjustedTotalNewEl.textContent = formatMoney(totalNew);
adjustedTotalFinalEl.textContent = formatMoney(totalNew);
adjustedPricesEl.classList.remove('hidden');
}
function clearCustomPrice() {
const customPriceInput = document.getElementById('custom-price-input');
const adjustedPricesEl = document.getElementById('adjusted-prices');
const discountInfoEl = document.getElementById('discount-info');
if (customPriceInput) customPriceInput.value = '';
if (adjustedPricesEl) adjustedPricesEl.classList.add('hidden');
if (discountInfoEl) discountInfoEl.classList.add('hidden');
triggerAutoSave();
}
async function exportCSVWithCustomPrice() {
if (cart.length === 0) return;
if (priceLevelsRefreshTimer) {
clearTimeout(priceLevelsRefreshTimer);
priceLevelsRefreshTimer = null;
}
await refreshPriceLevels({ force: true, noCache: true });
const customPrice = parseFloat(document.getElementById('custom-price-input').value) || 0;
const originalTotal = cart.reduce((sum, item) => sum + (getDisplayPrice(item) * item.quantity), 0);
if (customPrice <= 0 || originalTotal <= 0) {
showToast('Введите целевую цену', 'error');
return;
}
const coefficient = customPrice / originalTotal;
// Create adjusted cart
const adjustedCart = cart.map(item => ({
...item,
unit_price: parseFloat((getDisplayPrice(item) * coefficient).toFixed(2))
}));
try {
const resp = await fetch('/api/export/csv', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({items: adjustedCart, name: configName, project_uuid: projectUUID})
});
const blob = await resp.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = getFilenameFromResponse(resp) || (configName || 'config') + '.csv';
a.click();
window.URL.revokeObjectURL(url);
} catch(e) {
showToast('Ошибка экспорта', 'error');
}
}
async function refreshPrices() {
// RBAC disabled - no token check required
if (!configUUID) return;
if (disablePriceRefresh) {
showToast('Обновление цен отключено в настройках', 'error');
return;
}
try {
const resp = await fetch('/api/configs/' + configUUID + '/refresh-prices', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
});
if (!resp.ok) {
showToast('Ошибка обновления цен', 'error');
return;
}
const config = await resp.json();
// Update cart with new prices
if (config.items && config.items.length > 0) {
cart = config.items.map(item => ({
lot_name: item.lot_name,
quantity: item.quantity,
unit_price: item.unit_price,
estimate_price: item.unit_price,
warehouse_price: null,
competitor_price: null,
description: item.description || '',
category: item.category || getCategoryFromLotName(item.lot_name)
}));
}
// Update price update date
if (config.price_updated_at) {
updatePriceUpdateDate(config.price_updated_at);
}
if (config.pricelist_id) {
if (selectedPricelistIds.estimate) {
selectedPricelistIds.estimate = config.pricelist_id;
} else {
resolvedAutoPricelistIds.estimate = Number(config.pricelist_id);
}
if (!activePricelistsBySource.estimate.some(opt => Number(opt.id) === Number(config.pricelist_id))) {
await loadActivePricelists();
}
syncPriceSettingsControls();
renderPricelistSettingsSummary();
if (selectedPricelistIds.estimate) {
persistLocalPriceSettings();
}
}
// Re-render UI
await refreshPriceLevels();
renderTab();
updateCartUI();
showToast('Цены обновлены', 'success');
} catch(e) {
showToast('Ошибка обновления цен', 'error');
}
}
function updatePriceUpdateDate(dateStr) {
if (!dateStr) {
document.getElementById('price-update-date').textContent = '';
return;
}
const date = new Date(dateStr);
const now = new Date();
const diffMs = now - date;
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
let timeAgo;
if (diffMins < 1) {
timeAgo = 'только что';
} else if (diffMins < 60) {
timeAgo = diffMins + ' мин. назад';
} else if (diffHours < 24) {
timeAgo = diffHours + ' ч. назад';
} else if (diffDays < 7) {
timeAgo = diffDays + ' дн. назад';
} else {
timeAgo = date.toLocaleDateString('ru-RU');
}
document.getElementById('price-update-date').textContent = 'Обновлено: ' + timeAgo;
}
// ==================== TOP-LEVEL TABS ====================
let currentTopTab = 'estimate';
function switchTopTab(tab) {
currentTopTab = tab;
const tabs = ['estimate', 'bom', 'pricing'];
tabs.forEach(t => {
const btn = document.getElementById('top-tab-' + t);
const section = document.getElementById('top-section-' + t);
if (t === tab) {
btn.classList.remove('border-transparent', 'text-gray-500');
btn.classList.add('border-blue-600', 'text-blue-600');
section.classList.remove('hidden');
} else {
btn.classList.remove('border-blue-600', 'text-blue-600');
btn.classList.add('border-transparent', 'text-gray-500');
section.classList.add('hidden');
}
});
if (tab === 'pricing') {
renderPricingTab();
}
}
// ==================== BOM ВЕНДОРА ====================
let bomRows = []; // [{vendor_pn, quantity, description, unit_price, total_price, resolved_lot, resolution_source}]
let bomImportRaw = null; // { mode:'raw'|'parsed', rows, columnTypes, ignoredRows, rowErrors, uiError }
const BOM_COL_TYPES = [
{ value: 'ignore', label: 'Не использовать' },
{ value: 'pn', label: 'P/N' },
{ value: 'qty', label: 'Кол-во' },
{ value: 'price', label: 'Цена' },
{ value: 'description', label: 'Описание' }
];
function _bomRawHeaderWidthClass(colType) {
switch (colType) {
case 'qty': return 'w-24 min-w-24';
case 'price': return 'w-32 min-w-32';
case 'pn': return 'min-w-40';
case 'description': return 'min-w-48';
default: return 'min-w-28';
}
}
function _bomRawCellWidthClass(colType) {
switch (colType) {
case 'qty': return 'w-24 min-w-24';
case 'price': return 'w-32 min-w-32';
default: return '';
}
}
function parsePastePrice(s) {
if (!s) return null;
let v = String(s).replace(/[$\s]/g, '');
if (/,\d{1,2}$/.test(v)) v = v.replace(/\./g, '').replace(',', '.');
else v = v.replace(/,/g, '');
const n = parseFloat(v);
return isNaN(n) ? null : n;
}
function _ensureBomDatalist() {
let dl = document.getElementById('lot-autocomplete-list');
if (!dl) {
dl = document.createElement('datalist');
dl.id = 'lot-autocomplete-list';
document.body.appendChild(dl);
}
const all = _bomLots();
const cartLots = [];
const seenCart = new Set();
(window._currentCart || []).forEach(item => {
const lot = (item?.lot_name || '').trim();
if (!lot || seenCart.has(lot)) return;
seenCart.add(lot);
cartLots.push(lot);
});
const mappedSet = new Set();
bomRows.forEach(r => {
_getRowCanonicalLotMappings(r).forEach(m => {
if (m?.lot_name) mappedSet.add(m.lot_name);
});
});
const priorityLots = cartLots.filter(l => !mappedSet.has(l));
const prioritySet = new Set(priorityLots);
const rest = all.filter(l => !prioritySet.has(l));
const parts = [];
priorityLots.forEach(l => parts.push(`<option value="${escapeHtml(l)}">`));
if (priorityLots.length && rest.length) {
// Visual separator inside datalist suggestions.
parts.push(`<option value="${BOM_LOT_DATALIST_DIVIDER}">`);
}
rest.forEach(l => parts.push(`<option value="${escapeHtml(l)}">`));
dl.innerHTML = parts.join('');
return dl;
}
function _setBomUIError(msg) {
const el = document.getElementById('bom-errors');
if (!el) return;
if (msg) {
el.textContent = msg;
el.classList.remove('hidden');
} else {
el.textContent = '';
el.classList.add('hidden');
}
}
function _bomRawColCount() {
if (!bomImportRaw || !Array.isArray(bomImportRaw.rows)) return 0;
return bomImportRaw.rows.reduce((m, r) => Math.max(m, Array.isArray(r) ? r.length : 0), 0);
}
function _normalizeBomRawRows(rows) {
const ncols = rows.reduce((m, r) => Math.max(m, r.length), 0);
return rows.map(r => {
const row = r.slice();
while (row.length < ncols) row.push('');
return row;
});
}
function handleBOMPaste(event) {
event.preventDefault();
const text = event.clipboardData.getData('text/plain');
if (!text || !text.trim()) return;
const lines = text.split(/\r?\n/).filter(l => l.length > 0);
if (!lines.length) return;
const rows = lines.map(l => l.split('\t').map(c => c.trim()));
const normalized = _normalizeBomRawRows(rows);
if (!normalized.length || !(normalized[0] && normalized[0].length)) return;
bomImportRaw = {
mode: 'raw',
rows: normalized,
columnTypes: Array(normalized[0]?.length || 0).fill('ignore'),
ignoredRows: {},
rowErrors: {},
uiError: ''
};
bomRows = [];
_setBomUIError('');
renderBOMTable();
}
function _getBomColumnTypeIndexes() {
if (!bomImportRaw || !Array.isArray(bomImportRaw.columnTypes)) return null;
const idx = { ignore: [], pn: [], qty: [], price: [], description: [] };
bomImportRaw.columnTypes.forEach((t, i) => { (idx[t] || idx.ignore).push(i); });
return idx;
}
function _validateBomColumnTypes() {
if (!bomImportRaw || bomImportRaw.mode !== 'raw') return { ok: true };
const idx = _getBomColumnTypeIndexes();
if (!idx) return { ok: false, message: 'Нет данных BOM' };
if (idx.pn.length !== 1) return { ok: false, message: 'Выберите ровно один столбец P/N.' };
if (idx.qty.length !== 1) return { ok: false, message: 'Выберите ровно один столбец Кол-во.' };
if (idx.price.length > 1) return { ok: false, message: 'Можно выбрать только один столбец Цена.' };
if (idx.description.length > 1) return { ok: false, message: 'Можно выбрать только один столбец Описание.' };
return { ok: true, idx };
}
function onBOMColumnTypeChange(colIdx, value) {
if (!bomImportRaw || bomImportRaw.mode !== 'raw') return;
bomImportRaw.columnTypes[colIdx] = value;
rebuildBOMRowsFromRaw();
}
function onBOMRawCellInput(rowIdx, colIdx, value) {
if (!bomImportRaw || bomImportRaw.mode !== 'raw' || !bomImportRaw.rows[rowIdx]) return;
bomImportRaw.rows[rowIdx][colIdx] = value;
debouncedRebuildBOMFromRaw();
}
function toggleBOMRawRowIgnored(rowIdx) {
if (!bomImportRaw || bomImportRaw.mode !== 'raw') return;
bomImportRaw.ignoredRows[rowIdx] = !bomImportRaw.ignoredRows[rowIdx];
rebuildBOMRowsFromRaw();
}
function deleteBOMRawRow(rowIdx) {
if (!bomImportRaw || bomImportRaw.mode !== 'raw') return;
bomImportRaw.rows.splice(rowIdx, 1);
const nextIgnored = {};
const nextErrors = {};
Object.entries(bomImportRaw.ignoredRows || {}).forEach(([k, v]) => {
const n = Number(k);
if (n < rowIdx) nextIgnored[n] = v;
if (n > rowIdx) nextIgnored[n - 1] = v;
});
Object.entries(bomImportRaw.rowErrors || {}).forEach(([k, v]) => {
const n = Number(k);
if (n < rowIdx) nextErrors[n] = v;
if (n > rowIdx) nextErrors[n - 1] = v;
});
bomImportRaw.ignoredRows = nextIgnored;
bomImportRaw.rowErrors = nextErrors;
rebuildBOMRowsFromRaw();
}
function _bomRawLotCell(rowIdx) {
if (!bomImportRaw || bomImportRaw.mode !== 'raw') return '—';
if (bomImportRaw.ignoredRows?.[rowIdx]) return '<span class="text-gray-400">—</span>';
const validation = _validateBomColumnTypes();
if (!validation.ok) return '<span class="text-gray-400 text-xs">Выберите P/N и Кол-во</span>';
const map = bomRows.find(r => r.source_row_index === rowIdx);
if (!map) {
const err = bomImportRaw.rowErrors?.[rowIdx];
return err ? `<span class="text-red-600 text-xs">${escapeHtml(err)}</span>` : '<span class="text-gray-400">—</span>';
}
const cart = window._currentCart || [];
const cartMap = {};
cart.forEach(item => { cartMap[item.lot_name] = item.quantity; });
const isUnresolved = !map.resolved_lot || map.resolution_source === 'unresolved';
const cartQty = map.resolved_lot ? (cartMap[map.resolved_lot] ?? null) : null;
const qtyMismatch = cartQty !== null && cartQty !== map.quantity;
const notInCart = map.resolved_lot && cartQty === null;
if (isUnresolved) {
const val = map.manual_lot || '';
const invalid = val && !_bomLotValid(val);
return `<input type="text" placeholder="LOT..." value="${escapeHtml(val)}"
class="w-full min-w-28 px-2 py-1 border rounded text-xs ${invalid ? 'border-red-400 bg-red-50' : ''}"
list="lot-autocomplete-list"
oninput="setBOMManualLotDraft(${rowIdx}, this.value, this)"
onchange="commitBOMManualLot(${rowIdx}, this)"
onblur="commitBOMManualLot(${rowIdx}, this)">
${renderBOMLotAllocationsEditor(rowIdx)}`;
}
let suffix = '';
if (qtyMismatch) suffix = ` <span class="text-yellow-600 text-xs">≠est(${cartQty})</span>`;
else if (notInCart) suffix = ` <span class="text-orange-500 text-xs">новый</span>`;
return `<div><span class="font-mono text-xs">${escapeHtml(map.resolved_lot)}</span>${suffix}</div>${renderBOMLotAllocationsEditor(rowIdx)}`;
}
function commitBOMManualLot(rowIdx, el) {
const row = bomRows.find(r => r.source_row_index === rowIdx);
if (!row || !el) return;
const v = (el.value || '').trim();
if (!v) {
row.manual_lot = '';
el.value = '';
debouncedResolveBOM();
return;
}
if (!_bomLotValid(v)) {
el.value = row.manual_lot || '';
el.classList.add('border-red-400', 'bg-red-50');
return;
}
row.manual_lot = v;
el.classList.remove('border-red-400', 'bg-red-50');
debouncedResolveBOM();
}
function setBOMManualLotDraft(rowIdx, value, el) {
const row = bomRows.find(r => r.source_row_index === rowIdx);
if (!row) return;
row.manual_lot = value;
if (el) {
const invalid = value && !_bomLotValid(value);
el.classList.toggle('border-red-400', !!invalid);
el.classList.toggle('bg-red-50', !!invalid);
}
}
function _getRowAllocations(row) {
const list = Array.isArray(row?.lot_allocations) ? row.lot_allocations : [];
return list.map(a => ({
lot_name: (a?.lot_name || '').trim(),
quantity: Math.max(1, parseInt(a?.quantity, 10) || 1)
}));
}
function _getRowLotQtyPerPN(row) {
const q = parseInt(row?.lot_qty_per_pn, 10);
return (Number.isFinite(q) && q > 0) ? q : 1;
}
function _getRowBaseLot(row) {
if (row?.resolved_lot) return row.resolved_lot;
const manual = (row?.manual_lot || '').trim();
if (manual && _bomLotValid(manual)) return manual;
return '';
}
function _getRowCanonicalLotMappings(row) {
const out = [];
const baseLot = _getRowBaseLot(row);
if (baseLot && _bomLotValid(baseLot)) {
out.push({
lot_name: baseLot,
quantity_per_pn: _getRowLotQtyPerPN(row)
});
}
_getRowAllocations(row)
.filter(a => a.lot_name && _bomLotValid(a.lot_name) && a.quantity >= 1)
.forEach(a => {
out.push({
lot_name: a.lot_name,
quantity_per_pn: a.quantity
});
});
return out;
}
function _rowHasValidAllocations(row) {
const list = _getRowAllocations(row).filter(a => a.lot_name && _bomLotValid(a.lot_name) && a.quantity >= 1);
return list.length > 0;
}
function _rowBundleEnabled(row) {
return !!row?.bundle_enabled || _getRowAllocations(row).length > 0;
}
function _ensureRowAllocations(row) {
if (!row) return;
if (!Array.isArray(row.lot_allocations)) row.lot_allocations = [];
}
function renderBOMLotAllocationsEditor(rowIdx) {
const row = bomRows.find(r => r.source_row_index === rowIdx) || bomRows[rowIdx];
if (!row) return '';
_ensureRowAllocations(row);
if (!_rowBundleEnabled(row)) return '';
const allocs = _getRowAllocations(row);
const rowsHtml = allocs.map((a, i) => {
const invalidLot = a.lot_name && !_bomLotValid(a.lot_name);
return `<div class="${i === 0 ? '' : 'mt-1'}">
<input type="text" value="${escapeHtml(a.lot_name)}" placeholder="LOT"
class="w-full min-w-0 px-2 py-0.5 border rounded text-[11px] ${invalidLot ? 'border-red-400 bg-red-50' : ''}"
list="lot-autocomplete-list"
oninput="setBOMAllocationDraft(${rowIdx}, ${i}, 'lot_name', this.value, this)"
onchange="commitBOMAllocation(${rowIdx}, ${i}, 'lot_name', this)"
onblur="commitBOMAllocation(${rowIdx}, ${i}, 'lot_name', this)">
</div>`;
}).join('');
return `<div class="mt-1 pt-1 border-t border-gray-100 flex flex-col gap-1">${rowsHtml}</div>`;
}
function renderBOMLotAllocationsQtyEditor(rowIdx) {
const row = bomRows.find(r => r.source_row_index === rowIdx) || bomRows[rowIdx];
if (!row) return '';
_ensureRowAllocations(row);
if (!_rowBundleEnabled(row)) return '';
const allocs = _getRowAllocations(row);
const baseQtyHtml = `<div class="flex items-center gap-1">
<input type="number" min="1" step="1" value="${_getRowLotQtyPerPN(row)}"
class="w-16 px-1 py-0.5 border rounded text-[11px] text-right"
oninput="setBOMSingleLotQtyDraft(${rowIdx}, this.value)"
onchange="commitBOMSingleLotQty(${rowIdx}, this)"
onblur="commitBOMSingleLotQty(${rowIdx}, this)">
<span class="text-[10px] text-gray-500">/PN</span>
</div>`;
const rowsHtml = allocs.map((a, i) => `
<div class="flex items-center gap-1">
<input type="number" min="1" step="1" value="${a.quantity}"
class="w-16 px-1 py-0.5 border rounded text-[11px] text-right"
oninput="setBOMAllocationDraft(${rowIdx}, ${i}, 'quantity', this.value, this)"
onchange="commitBOMAllocation(${rowIdx}, ${i}, 'quantity', this)"
onblur="commitBOMAllocation(${rowIdx}, ${i}, 'quantity', this)">
<span class="text-[10px] text-gray-500">/PN</span>
<button type="button" class="text-[11px] text-gray-400 hover:text-red-600 px-1" title="Удалить LOT" onclick="removeBOMAllocation(${rowIdx}, ${i})">✕</button>
</div>`).join('');
return `<div class="mt-1 pt-1 border-t border-gray-100 flex flex-col gap-1">${baseQtyHtml}${rowsHtml}</div>`;
}
function _findBOMRowBySource(rowIdx) {
return bomRows.find(r => r.source_row_index === rowIdx) || bomRows[rowIdx] || null;
}
function addBOMAllocation(rowIdx) {
const row = _findBOMRowBySource(rowIdx);
if (!row) return;
_ensureRowAllocations(row);
row.bundle_enabled = true;
row.lot_allocations.push({ lot_name: row.resolved_lot || '', quantity: 1 });
renderBOMTable();
debouncedAutosaveBOM();
}
function removeBOMAllocation(rowIdx, allocIdx) {
const row = _findBOMRowBySource(rowIdx);
if (!row || !Array.isArray(row.lot_allocations)) return;
row.lot_allocations.splice(allocIdx, 1);
if (!row.lot_allocations.length) row.bundle_enabled = false;
renderBOMTable();
debouncedAutosaveBOM();
}
function setBOMAllocationDraft(rowIdx, allocIdx, field, value, el) {
const row = _findBOMRowBySource(rowIdx);
if (!row) return;
_ensureRowAllocations(row);
if (!row.lot_allocations[allocIdx]) row.lot_allocations[allocIdx] = { lot_name: '', quantity: 1 };
if (field === 'lot_name') {
row.lot_allocations[allocIdx].lot_name = value;
if (el) {
const invalid = value && !_bomLotValid(value);
el.classList.toggle('border-red-400', !!invalid);
el.classList.toggle('bg-red-50', !!invalid);
}
} else if (field === 'quantity') {
row.lot_allocations[allocIdx].quantity = value;
}
}
function setBOMSingleLotQtyDraft(rowIdx, value) {
const row = _findBOMRowBySource(rowIdx);
if (!row) return;
row.lot_qty_per_pn = value;
}
function commitBOMSingleLotQty(rowIdx, el) {
const row = _findBOMRowBySource(rowIdx);
if (!row) return;
row.lot_qty_per_pn = _getRowLotQtyPerPN({ lot_qty_per_pn: el?.value });
if (el) el.value = String(row.lot_qty_per_pn);
renderBOMTable();
debouncedAutosaveBOM();
}
function commitBOMAllocation(rowIdx, allocIdx, field, el) {
const row = _findBOMRowBySource(rowIdx);
if (!row || !Array.isArray(row.lot_allocations) || !row.lot_allocations[allocIdx]) return;
const a = row.lot_allocations[allocIdx];
if (field === 'lot_name') {
const v = (el?.value || '').trim();
if (v && !_bomLotValid(v)) {
if (el) {
el.value = (a.lot_name && _bomLotValid(a.lot_name)) ? a.lot_name : '';
el.classList.add('border-red-400', 'bg-red-50');
}
return;
}
a.lot_name = v;
if (el) el.classList.remove('border-red-400', 'bg-red-50');
} else if (field === 'quantity') {
const q = parseInt(el?.value, 10);
a.quantity = (Number.isFinite(q) && q > 0) ? q : 1;
if (el) el.value = String(a.quantity);
}
renderBOMTable();
debouncedAutosaveBOM();
}
let _rebuildBOMTimer = null;
function debouncedRebuildBOMFromRaw() {
clearTimeout(_rebuildBOMTimer);
_rebuildBOMTimer = setTimeout(() => rebuildBOMRowsFromRaw(), 350);
}
function rebuildBOMRowsFromRaw() {
if (!bomImportRaw || bomImportRaw.mode !== 'raw') return;
bomImportRaw.rowErrors = {};
const validation = _validateBomColumnTypes();
if (!validation.ok) {
bomRows = [];
_setBomUIError(validation.message);
renderBOMTable();
if (currentTopTab === 'pricing') renderPricingTab();
return;
}
_setBomUIError('');
const idx = validation.idx;
const pnCol = idx.pn[0];
const qtyCol = idx.qty[0];
const priceCol = idx.price.length ? idx.price[0] : -1;
const descCol = idx.description.length ? idx.description[0] : -1;
const nextRows = [];
bomImportRaw.rows.forEach((cols, rowIdx) => {
if (bomImportRaw.ignoredRows?.[rowIdx]) return;
const pn = ((cols[pnCol] || '') + '').trim();
if (!pn) return;
const qtyRaw = ((cols[qtyCol] || '') + '').trim();
if (!/^\d+$/.test(qtyRaw) || parseInt(qtyRaw, 10) < 1) {
bomImportRaw.rowErrors[rowIdx] = 'Некорректное кол-во';
return;
}
const quantity = parseInt(qtyRaw, 10);
const description = descCol !== -1 ? ((cols[descCol] || '') + '').trim() : '';
const unit_price = priceCol !== -1 ? parsePastePrice(cols[priceCol] || '') : null;
const total_price = unit_price != null ? unit_price * quantity : null;
const prev = bomRows.find(r => r.source_row_index === rowIdx) || {};
nextRows.push({
sort_order: (nextRows.length + 1) * 10,
vendor_pn: pn,
quantity,
description,
unit_price,
total_price,
resolved_lot: prev.resolved_lot || '',
resolution_source: prev.resolution_source || 'unresolved',
manual_lot: (prev.manual_lot && _bomLotValid(prev.manual_lot)) ? prev.manual_lot : '',
lot_qty_per_pn: _getRowLotQtyPerPN(prev),
lot_allocations: _getRowAllocations(prev),
bundle_enabled: _rowBundleEnabled(prev),
source_row_index: rowIdx
});
});
bomRows = nextRows;
renderBOMTable();
if (!bomRows.length) {
if (currentTopTab === 'pricing') renderPricingTab();
return;
}
debouncedResolveBOM();
}
async function resolveBOM() {
if (!configUUID) {
renderBOMTable();
return;
}
const specPayload = bomRows.map(r => ({
sort_order: r.sort_order,
vendor_partnumber: r.vendor_pn,
quantity: r.quantity,
description: r.description,
unit_price: r.unit_price,
total_price: r.total_price,
manual_lot_suggestion: (r.manual_lot && _bomLotValid(r.manual_lot)) ? r.manual_lot : '',
lot_allocations: _getRowAllocations(r).filter(a => a.lot_name && _bomLotValid(a.lot_name) && a.quantity >= 1)
}));
try {
const resp = await fetch(`/api/configs/${configUUID}/vendor-spec/resolve`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({vendor_spec: specPayload})
});
if (!resp.ok) throw new Error(await resp.text());
const data = await resp.json();
if (data.resolved) {
data.resolved.forEach((r, i) => {
if (bomRows[i]) {
bomRows[i].resolved_lot = r.resolved_lot_name || '';
bomRows[i].resolution_source = r.resolution_source || 'unresolved';
}
});
}
const unseen = bomRows
.filter(r => !r.resolved_lot || r.resolution_source === 'unresolved')
.map(r => ({ partnumber: r.vendor_pn, description: r.description || '', ignored: false }));
let ignoredSeen = [];
if (bomImportRaw && bomImportRaw.mode === 'raw') {
const validation = _validateBomColumnTypes();
if (validation.ok) {
const pnCol = validation.idx.pn[0];
const descCol = validation.idx.description.length ? validation.idx.description[0] : -1;
ignoredSeen = (bomImportRaw.rows || [])
.map((cols, rowIdx) => ({ cols, rowIdx }))
.filter(x => bomImportRaw.ignoredRows?.[x.rowIdx])
.map(({ cols }) => ({
partnumber: ((cols[pnCol] || '') + '').trim(),
description: descCol !== -1 ? ((cols[descCol] || '') + '').trim() : '',
ignored: true
}))
.filter(x => x.partnumber);
}
}
const partnumberSeenPayload = [...unseen, ...ignoredSeen];
if (partnumberSeenPayload.length) {
fetch('/api/sync/partnumber-seen', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({items: partnumberSeenPayload})
}).catch(() => {});
}
} catch (e) {
console.warn('Resolution failed:', e);
}
renderBOMTable();
debouncedAutosaveBOM();
}
function _renderBOMParsedTable() {
_setBomUIError('');
_ensureBomDatalist();
const thead = document.getElementById('bom-table-head');
const tbody = document.getElementById('bom-table-body');
thead.innerHTML = `
<tr>
<th class="px-3 py-2 text-left border-b">PN вендора</th>
<th class="px-3 py-2 text-right border-b">Кол-во</th>
<th class="px-3 py-2 text-left border-b">Описание</th>
<th class="px-3 py-2 text-right border-b">Цена ед.</th>
<th class="px-3 py-2 text-right border-b">Итого</th>
<th class="px-3 py-2 text-left border-b">LOT</th>
</tr>`;
const cart = window._currentCart || [];
const cartMap = {};
cart.forEach(item => { cartMap[item.lot_name] = item.quantity; });
let unresolved = 0, mismatches = 0;
tbody.innerHTML = '';
bomRows.forEach((row, idx) => {
const tr = document.createElement('tr');
const isUnresolved = !row.resolved_lot || row.resolution_source === 'unresolved';
const cartQty = row.resolved_lot ? (cartMap[row.resolved_lot] ?? null) : null;
const qtyMismatch = cartQty !== null && cartQty !== row.quantity;
const notInCart = row.resolved_lot && cartQty === null;
if (isUnresolved) unresolved++;
if (qtyMismatch || notInCart) mismatches++;
tr.className = isUnresolved ? 'bg-red-50' : (qtyMismatch ? 'bg-yellow-50' : (notInCart ? 'bg-orange-50' : ''));
let lotCell = '';
if (isUnresolved) {
lotCell = `<input type="text" placeholder="Введите LOT..." value="${escapeHtml(row.manual_lot || '')}"
class="w-full px-2 py-1 border rounded text-sm focus:ring-1 focus:ring-blue-400"
oninput="bomRows[${idx}].manual_lot = this.value; this.classList.toggle('border-red-400', this.value && !_bomLotValid(this.value));"
onchange="if(_bomLotValid(this.value)){bomRows[${idx}].manual_lot=this.value;resolveBOM(); this.classList.remove('border-red-400');}else{this.value=bomRows[${idx}].manual_lot||'';}"
onblur="if(this.value && !_bomLotValid(this.value)){this.value=bomRows[${idx}].manual_lot||'';}"
list="lot-autocomplete-list">${renderBOMLotAllocationsEditor(idx)}`;
} else {
let suffix = '';
if (qtyMismatch) suffix = ` <span class="text-yellow-600 text-xs">≠est(${cartQty})</span>`;
else if (notInCart) suffix = ` <span class="text-orange-500 text-xs">новый</span>`;
lotCell = `<div><span class="font-mono text-xs">${row.resolved_lot}</span>${suffix}</div>${renderBOMLotAllocationsEditor(idx)}`;
}
tr.innerHTML = `
<td class="px-3 py-1.5 font-mono text-xs">${escapeHtml(row.vendor_pn)}</td>
<td class="px-3 py-1.5 text-right">${row.quantity}</td>
<td class="px-3 py-1.5 text-gray-600 text-xs">${escapeHtml(row.description || '')}</td>
<td class="px-3 py-1.5 text-right text-xs">${row.unit_price != null ? formatCurrency(row.unit_price) : '—'}</td>
<td class="px-3 py-1.5 text-right text-xs">${row.total_price != null ? formatCurrency(row.total_price) : '—'}</td>
<td class="px-3 py-1.5">${lotCell}</td>`;
tbody.appendChild(tr);
});
document.getElementById('bom-stats').textContent = `Строк: ${bomRows.length} | Не сопоставлено: ${unresolved} | Расхождений: ${mismatches}`;
}
function _renderBOMRawTable() {
_ensureBomDatalist();
const thead = document.getElementById('bom-table-head');
const tbody = document.getElementById('bom-table-body');
const colCount = _bomRawColCount();
const validation = _validateBomColumnTypes();
const allRows = bomImportRaw?.rows || [];
const headCols = Array.from({length: colCount}, (_, i) => {
const colType = bomImportRaw.columnTypes[i] || 'ignore';
return `<th class="px-2 py-2 text-left border-b align-top min-w-28">
<select class="w-full text-xs border rounded px-1 py-1 ${_bomRawHeaderWidthClass(colType)} max-w-full" onchange="onBOMColumnTypeChange(${i}, this.value)">
${BOM_COL_TYPES.map(t => `<option value="${t.value}" ${bomImportRaw.columnTypes[i] === t.value ? 'selected' : ''}>${t.label}</option>`).join('')}
</select>
</th>`;
}).join('');
thead.innerHTML = `<tr>${headCols}<th class="px-2 py-2 text-left border-b min-w-72 w-80">LOT</th><th class="px-2 py-2 text-left border-b w-28 min-w-28">LOT в 1 PN</th><th class="w-12 px-1 py-2 text-center border-b"></th></tr>`;
tbody.innerHTML = '';
let unresolved = 0, mismatches = 0;
const cart = window._currentCart || [];
const cartMap = {};
cart.forEach(item => { cartMap[item.lot_name] = item.quantity; });
allRows.forEach((cols, rowIdx) => {
const tr = document.createElement('tr');
const ignored = !!bomImportRaw.ignoredRows?.[rowIdx];
const parsed = bomRows.find(r => r.source_row_index === rowIdx);
const rowErr = bomImportRaw.rowErrors?.[rowIdx];
if (ignored) tr.className = 'opacity-60 bg-gray-50';
else if (rowErr) tr.className = 'bg-red-50';
else if (parsed) {
const isUnresolved = !parsed.resolved_lot || parsed.resolution_source === 'unresolved';
const cartQty = parsed.resolved_lot ? (cartMap[parsed.resolved_lot] ?? null) : null;
const qtyMismatch = cartQty !== null && cartQty !== parsed.quantity;
const notInCart = parsed.resolved_lot && cartQty === null;
if (isUnresolved) unresolved++;
if (qtyMismatch || notInCart) mismatches++;
if (isUnresolved) tr.className = 'bg-red-50';
else if (qtyMismatch) tr.className = 'bg-yellow-50';
else if (notInCart) tr.className = 'bg-orange-50';
}
const rawCells = cols.map((v, colIdx) => {
const t = bomImportRaw.columnTypes[colIdx] || 'ignore';
const widthCls = _bomRawCellWidthClass(t);
return `<td class="px-2 py-1.5 border-b align-top ${widthCls}">
<div contenteditable="true" class="min-h-6 text-xs outline-none focus:ring-1 focus:ring-blue-300 rounded px-1 ${t === 'qty' ? 'text-right' : ''}"
onblur="onBOMRawCellInput(${rowIdx}, ${colIdx}, this.textContent)"
onkeydown="if(event.key==='Enter'){event.preventDefault(); this.blur();}">${escapeHtml(v || '')}</div>
</td>`;
}).join('');
const lotHtml = _bomRawLotCell(rowIdx);
const lotQtyHtml = parsed
? (_rowBundleEnabled(parsed)
? renderBOMLotAllocationsQtyEditor(rowIdx)
: `<input type="number" min="1" step="1" value="${_getRowLotQtyPerPN(parsed)}"
class="w-20 px-1 py-0.5 border rounded text-xs text-right"
oninput="setBOMSingleLotQtyDraft(${rowIdx}, this.value)"
onchange="commitBOMSingleLotQty(${rowIdx}, this)"
onblur="commitBOMSingleLotQty(${rowIdx}, this)">`)
: '<div class="text-xs text-gray-400 px-1">—</div>';
const errHint = rowErr ? `<div class="text-[10px] text-red-600 mt-1">${escapeHtml(rowErr)}</div>` : '';
tr.innerHTML = `
${rawCells}
<td class="px-2 py-1.5 border-b align-top min-w-72 w-80">${lotHtml}${errHint}</td>
<td class="px-2 py-1.5 border-b align-top w-28 min-w-28">${lotQtyHtml}</td>
<td class="w-12 px-1 py-1 border-b text-center align-top whitespace-nowrap">
<button type="button" title="Добавить LOT в bundle" onclick="addBOMAllocation(${rowIdx})" class="inline-block text-xs px-1 text-gray-400 hover:text-blue-600">+</button>
<button type="button" title="${ignored ? 'Не игнорировать' : 'Игнорировать'}" onclick="toggleBOMRawRowIgnored(${rowIdx})" class="inline-block text-xs px-1 ${ignored ? 'text-blue-600' : 'text-gray-400 hover:text-gray-700'}">${ignored ? '◉' : '○'}</button>
<button type="button" title="Удалить строку" onclick="deleteBOMRawRow(${rowIdx})" class="inline-block text-xs px-1 text-gray-400 hover:text-red-600">✕</button>
</td>`;
tbody.appendChild(tr);
});
const statsParts = [`Строк raw: ${allRows.length}`, `В BOM: ${bomRows.length}`];
if (validation.ok) {
statsParts.push(`Не сопоставлено: ${unresolved}`);
statsParts.push(`Расхождений: ${mismatches}`);
} else {
statsParts.push('Ожидается выбор колонок');
}
document.getElementById('bom-stats').textContent = statsParts.join(' | ');
}
function renderBOMTable() {
document.getElementById('bom-table-container').classList.remove('hidden');
if (bomImportRaw && bomImportRaw.mode === 'raw') _renderBOMRawTable();
else _renderBOMParsedTable();
if (currentTopTab === 'pricing') renderPricingTab();
}
let _resolveBOMTimer = null;
function debouncedResolveBOM() {
clearTimeout(_resolveBOMTimer);
_resolveBOMTimer = setTimeout(() => resolveBOM(), 500);
}
async function clearBOM() {
if (bomRows.length && !confirm('Очистить BOM? Данные будут удалены с сервера.')) return;
bomRows = [];
bomImportRaw = null;
_setBomUIError('');
document.getElementById('bom-table-container').classList.add('hidden');
document.getElementById('bom-paste-area').textContent = 'Нажмите сюда и вставьте из Excel (Ctrl+V)...';
if (configUUID) {
await fetch(`/api/configs/${configUUID}/vendor-spec`, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({vendor_spec: []})
});
}
}
async function saveBOMInternal(showSuccessToast = true) {
if (!configUUID || !bomRows.length) return;
const spec = bomRows.map(r => ({
sort_order: r.sort_order,
vendor_partnumber: r.vendor_pn,
quantity: r.quantity,
description: r.description,
unit_price: r.unit_price,
total_price: r.total_price,
lot_mappings: _getRowCanonicalLotMappings(r)
}));
const resp = await fetch(`/api/configs/${configUUID}/vendor-spec`, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({vendor_spec: spec})
});
if (!resp.ok) throw new Error(await resp.text());
if (showSuccessToast) showToast('BOM сохранён', 'success');
}
let _autosaveBOMTimer = null;
function debouncedAutosaveBOM() {
clearTimeout(_autosaveBOMTimer);
_autosaveBOMTimer = setTimeout(async () => {
if (!configUUID || !bomRows.length) return;
try {
await saveBOMInternal(false);
} catch (e) {
console.warn('BOM autosave failed:', e);
}
}, 700);
}
async function saveBOM() {
try {
await saveBOMInternal(true);
} catch (e) {
showToast('Ошибка сохранения BOM: ' + e.message, 'error');
}
}
async function applyBOMToEstimate() {
if (!bomRows.length) return;
if (bomRows.some(r => r.manual_lot && !_bomLotValid(r.manual_lot))) {
alert('Есть несуществующий LOT в BOM. Выберите LOT из списка.');
return;
}
const resolved = bomRows.filter(r => _getRowBaseLot(r) || _rowHasValidAllocations(r));
if (!resolved.length) {
alert('Нет сопоставленных строк. Сначала настройте LOT для всех позиций.');
return;
}
// Aggregate quantities
const lotMap = {};
resolved.forEach(r => {
const baseLot = _getRowBaseLot(r);
const allocs = _getRowAllocations(r).filter(a => a.lot_name && _bomLotValid(a.lot_name) && a.quantity >= 1);
if (baseLot) {
if (!lotMap[baseLot]) lotMap[baseLot] = 0;
lotMap[baseLot] += r.quantity * _getRowLotQtyPerPN(r);
}
if (allocs.length) {
allocs.forEach(a => {
if (!lotMap[a.lot_name]) lotMap[a.lot_name] = 0;
lotMap[a.lot_name] += r.quantity * a.quantity;
});
return;
}
if (!baseLot) return;
});
const items = Object.entries(lotMap).map(([lot, qty]) => ({
lot_name: lot,
quantity: qty,
unit_price: 0
}));
const mappedRowsCount = resolved.length;
const unresolvedRowsCount = Math.max(0, bomRows.length - mappedRowsCount);
const confirmText = [
'Пересчитать Estimate?',
`Сопоставлено строк BOM: ${mappedRowsCount}`,
`Не сопоставлено строк BOM: ${unresolvedRowsCount}`,
`Текущая корзина будет заменена ${items.length} LOT-позициями.`
].join('\n');
if (!confirm(confirmText)) return;
try {
const resp = await fetch(`/api/configs/${configUUID}/vendor-spec/apply`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({items})
});
if (!resp.ok) throw new Error(await resp.text());
showToast('Estimate обновлён из BOM', 'success');
// Reload the page to show updated estimate
window.location.reload();
} catch (e) {
showToast('Ошибка: ' + e.message, 'error');
}
}
// Load existing BOM on config load
async function loadVendorSpec(configUUID) {
try {
const resp = await fetch(`/api/configs/${configUUID}/vendor-spec`);
if (!resp.ok) return;
const data = await resp.json();
if (data.vendor_spec && data.vendor_spec.length) {
bomRows = data.vendor_spec.map((r, i) => ({
_mappings: Array.isArray(r.lot_mappings) ? r.lot_mappings : [],
sort_order: r.sort_order || (i + 1) * 10,
vendor_pn: r.vendor_partnumber,
quantity: r.quantity,
description: r.description || '',
unit_price: r.unit_price || null,
total_price: r.total_price || null,
resolved_lot: '',
resolution_source: 'unresolved',
manual_lot: '',
lot_qty_per_pn: 1,
lot_allocations: [],
bundle_enabled: false,
source_row_index: i
})).map(row => {
const mappings = Array.isArray(row._mappings) ? row._mappings : [];
const validMappings = mappings
.map(m => ({
lot_name: (m?.lot_name || '').trim(),
quantity_per_pn: Math.max(1, parseInt(m?.quantity_per_pn, 10) || 1)
}))
.filter(m => m.lot_name);
if (validMappings.length) {
row.resolved_lot = validMappings[0].lot_name;
row.manual_lot = validMappings[0].lot_name;
row.resolution_source = 'manual_suggestion';
row.lot_qty_per_pn = validMappings[0].quantity_per_pn;
row.lot_allocations = validMappings.slice(1).map(m => ({
lot_name: m.lot_name,
quantity: m.quantity_per_pn
}));
row.bundle_enabled = row.lot_allocations.length > 0;
}
delete row._mappings;
return row;
});
// Reconstruct editable raw table from normalized vendor_spec (not original Excel paste).
// Columns: Qty | P/N | Description | Price
bomImportRaw = {
mode: 'raw',
rows: bomRows.map(r => ([
String(r.quantity ?? 1),
r.vendor_pn || '',
r.description || '',
r.unit_price != null ? String(r.unit_price) : ''
])),
columnTypes: ['qty', 'pn', 'description', 'price'],
ignoredRows: {},
rowErrors: {},
uiError: ''
};
renderBOMTable();
}
} catch (e) {
console.warn('Failed to load vendor spec:', e);
}
}
// ==================== ЦЕНООБРАЗОВАНИЕ ====================
async function renderPricingTab() {
const tbody = document.getElementById('pricing-table-body');
const tfoot = document.getElementById('pricing-table-foot');
const cart = window._currentCart || [];
const compMap = {};
(window._bomAllComponents || allComponents).forEach(c => { compMap[c.lot_name] = c; });
const rowBaseLot = (row) => {
if (row?.resolved_lot) return row.resolved_lot;
if (row?.manual_lot && _bomLotValid(row.manual_lot)) return row.manual_lot;
return '';
};
// Collect LOTs to price: from BOM rows (resolved) or from cart
let itemsForPriceLevels = [];
if (bomRows.length) {
const seen = new Set();
bomRows.forEach(row => {
const baseLot = rowBaseLot(row);
const allocs = _getRowAllocations(row).filter(a => a.lot_name && _bomLotValid(a.lot_name) && a.quantity >= 1);
if (baseLot && !seen.has(baseLot)) {
seen.add(baseLot);
itemsForPriceLevels.push({ lot_name: baseLot, quantity: row.quantity * _getRowLotQtyPerPN(row) });
}
if (allocs.length) {
allocs.forEach(a => {
if (!seen.has(a.lot_name)) {
seen.add(a.lot_name);
itemsForPriceLevels.push({ lot_name: a.lot_name, quantity: row.quantity * a.quantity });
}
});
}
});
// Also price LOTs that exist in current Estimate but are not covered by BOM mappings.
cart.forEach(item => {
if (!item?.lot_name || seen.has(item.lot_name)) return;
seen.add(item.lot_name);
itemsForPriceLevels.push({ lot_name: item.lot_name, quantity: item.quantity });
});
} else {
itemsForPriceLevels = cart.map(item => ({ lot_name: item.lot_name, quantity: item.quantity }));
}
// Fetch fresh price levels for these LOTs
const priceMap = {}; // lot_name → {estimate_price, ...}
if (itemsForPriceLevels.length) {
try {
const payload = {
items: itemsForPriceLevels,
pricelist_ids: Object.fromEntries(
Object.entries(selectedPricelistIds)
.filter(([, id]) => typeof id === 'number' && id > 0)
)
};
const resp = await fetch('/api/quote/price-levels', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (resp.ok) {
const data = await resp.json();
(data.items || []).forEach(i => { priceMap[i.lot_name] = i; });
}
} catch(e) { /* silent — pricing tab renders with available data */ }
}
let totalVendor = 0, totalEstimate = 0, totalWarehouse = 0;
let hasVendor = false, hasEstimate = false, hasWarehouse = false;
tbody.innerHTML = '';
if (!bomRows.length) {
if (!cart.length) {
tbody.innerHTML = '<tr><td colspan="9" class="px-3 py-8 text-center text-gray-400">Нет данных для отображения</td></tr>';
tfoot.classList.add('hidden');
return;
}
cart.forEach(item => {
const tr = document.createElement('tr');
tr.classList.add('pricing-row');
const pl = priceMap[item.lot_name];
const estUnit = (pl && pl.estimate_price > 0) ? pl.estimate_price : (item.unit_price || 0);
const warehouseUnit = (pl && pl.warehouse_price > 0) ? pl.warehouse_price : null;
const estimateTotal = estUnit * item.quantity;
const warehouseTotal = warehouseUnit != null ? warehouseUnit * item.quantity : null;
if (estimateTotal > 0) { totalEstimate += estimateTotal; hasEstimate = true; }
if (warehouseTotal != null && warehouseTotal > 0) { totalWarehouse += warehouseTotal; hasWarehouse = true; }
tr.dataset.est = estimateTotal;
const desc = (compMap[item.lot_name] || {}).description || '';
tr.dataset.vendorOrig = '';
tr.innerHTML = `
<td class="px-3 py-1.5 text-xs">${escapeHtml(item.lot_name)}</td>
<td class="px-3 py-1.5 text-xs text-gray-400">—</td>
<td class="px-3 py-1.5 text-xs text-gray-500 truncate max-w-xs">${escapeHtml(desc)}</td>
<td class="px-3 py-1.5 text-right">${item.quantity}</td>
<td class="px-3 py-1.5 text-right text-xs">${estimateTotal > 0 ? formatCurrency(estimateTotal) : '—'}</td>
<td class="px-3 py-1.5 text-right text-xs text-gray-400 pricing-vendor-price">—</td>
<td class="px-3 py-1.5 text-right text-xs">${warehouseTotal != null && warehouseTotal > 0 ? formatCurrency(warehouseTotal) : '—'}</td>
<td class="px-3 py-1.5 text-right text-xs text-gray-400">—</td>
`;
tbody.appendChild(tr);
});
} else {
const coveredLots = new Set();
bomRows.forEach(row => {
const tr = document.createElement('tr');
tr.classList.add('pricing-row');
const baseLot = rowBaseLot(row);
const allocs = _getRowAllocations(row).filter(a => a.lot_name && _bomLotValid(a.lot_name) && a.quantity >= 1);
if (baseLot) coveredLots.add(baseLot);
allocs.forEach(a => coveredLots.add(a.lot_name));
const hasMapping = !!baseLot || allocs.length > 0;
const isUnresolved = !hasMapping;
let rowEst = 0;
let hasEstimateForRow = false;
let rowWarehouse = 0;
let hasWarehouseForRow = false;
if (baseLot) {
const pl = priceMap[baseLot];
const estimateUnit = (pl && pl.estimate_price > 0) ? pl.estimate_price : null;
const warehouseUnit = (pl && pl.warehouse_price > 0) ? pl.warehouse_price : null;
if (estimateUnit != null) {
rowEst += estimateUnit * row.quantity * _getRowLotQtyPerPN(row);
hasEstimateForRow = true;
}
if (warehouseUnit != null) {
rowWarehouse += warehouseUnit * row.quantity * _getRowLotQtyPerPN(row);
hasWarehouseForRow = true;
}
}
allocs.forEach(a => {
const pl = priceMap[a.lot_name];
const estimateUnit = (pl && pl.estimate_price > 0) ? pl.estimate_price : null;
const warehouseUnit = (pl && pl.warehouse_price > 0) ? pl.warehouse_price : null;
if (estimateUnit != null) {
rowEst += estimateUnit * row.quantity * a.quantity;
hasEstimateForRow = true;
}
if (warehouseUnit != null) {
rowWarehouse += warehouseUnit * row.quantity * a.quantity;
hasWarehouseForRow = true;
}
});
const vendorTotal = row.total_price != null ? row.total_price : (row.unit_price != null ? row.unit_price * row.quantity : null);
if (vendorTotal != null) { totalVendor += vendorTotal; hasVendor = true; }
if (hasEstimateForRow) { totalEstimate += rowEst; hasEstimate = true; }
if (hasWarehouseForRow) { totalWarehouse += rowWarehouse; hasWarehouse = true; }
tr.dataset.est = rowEst;
tr.dataset.vendorOrig = vendorTotal != null ? vendorTotal : '';
const desc = row.description || (baseLot ? ((compMap[baseLot] || {}).description || '') : '');
let lotCell = '<span class="text-red-500">н/д</span>';
if (baseLot && allocs.length) {
lotCell = `${escapeHtml(baseLot)} <span class="text-gray-400">+${allocs.length}</span>`;
} else if (baseLot) {
lotCell = escapeHtml(baseLot);
} else if (allocs.length) {
lotCell = `${escapeHtml(allocs[0].lot_name)}${allocs.length > 1 ? ` <span class="text-gray-400">+${allocs.length - 1}</span>` : ''}`;
}
tr.innerHTML = `
<td class="px-3 py-1.5 text-xs">${lotCell}</td>
<td class="px-3 py-1.5 font-mono text-xs">${escapeHtml(row.vendor_pn)}</td>
<td class="px-3 py-1.5 text-xs text-gray-500 truncate max-w-xs">${escapeHtml(desc)}</td>
<td class="px-3 py-1.5 text-right">${row.quantity}</td>
<td class="px-3 py-1.5 text-right text-xs">${hasEstimateForRow ? formatCurrency(rowEst) : '—'}</td>
<td class="px-3 py-1.5 text-right text-xs pricing-vendor-price">${vendorTotal != null ? formatCurrency(vendorTotal) : '—'}</td>
<td class="px-3 py-1.5 text-right text-xs">${hasWarehouseForRow ? formatCurrency(rowWarehouse) : '—'}</td>
<td class="px-3 py-1.5 text-right text-xs text-gray-400">—</td>
`;
tbody.appendChild(tr);
});
// Append Estimate-only LOTs that were counted in cart but not mapped from BOM.
cart.forEach(item => {
if (!item?.lot_name || coveredLots.has(item.lot_name)) return;
const tr = document.createElement('tr');
tr.classList.add('pricing-row');
tr.classList.add('bg-blue-50');
const pl = priceMap[item.lot_name];
const estUnit = (pl && pl.estimate_price > 0) ? pl.estimate_price : (item.unit_price || 0);
const warehouseUnit = (pl && pl.warehouse_price > 0) ? pl.warehouse_price : null;
const estimateTotal = estUnit * item.quantity;
const warehouseTotal = warehouseUnit != null ? warehouseUnit * item.quantity : null;
if (estimateTotal > 0) { totalEstimate += estimateTotal; hasEstimate = true; }
if (warehouseTotal != null && warehouseTotal > 0) { totalWarehouse += warehouseTotal; hasWarehouse = true; }
tr.dataset.est = estimateTotal;
tr.dataset.vendorOrig = '';
const desc = (compMap[item.lot_name] || {}).description || '';
tr.innerHTML = `
<td class="px-3 py-1.5 text-xs">${escapeHtml(item.lot_name)}</td>
<td class="px-3 py-1.5 text-xs text-gray-400">—</td>
<td class="px-3 py-1.5 text-xs text-gray-500 truncate max-w-xs">${escapeHtml(desc)}</td>
<td class="px-3 py-1.5 text-right">${item.quantity}</td>
<td class="px-3 py-1.5 text-right text-xs">${estimateTotal > 0 ? formatCurrency(estimateTotal) : '—'}</td>
<td class="px-3 py-1.5 text-right text-xs text-gray-400 pricing-vendor-price">—</td>
<td class="px-3 py-1.5 text-right text-xs">${warehouseTotal != null && warehouseTotal > 0 ? formatCurrency(warehouseTotal) : '—'}</td>
<td class="px-3 py-1.5 text-right text-xs text-gray-400">—</td>
`;
tbody.appendChild(tr);
});
}
// Totals row
document.getElementById('pricing-total-vendor').textContent = hasVendor ? formatCurrency(totalVendor) : '—';
document.getElementById('pricing-total-estimate').textContent = hasEstimate ? formatCurrency(totalEstimate) : '—';
document.getElementById('pricing-total-warehouse').textContent = hasWarehouse ? formatCurrency(totalWarehouse) : '—';
tfoot.classList.remove('hidden');
// Update custom price proportional breakdown
onPricingCustomPriceInput();
}
function setPricingCustomPriceFromVendor() {
// Apply per-row BOM prices directly (not proportional redistribution)
const rows = document.querySelectorAll('#pricing-table-body tr.pricing-row');
const vendorCells = document.querySelectorAll('#pricing-table-body .pricing-vendor-price');
let total = 0;
let hasAny = false;
rows.forEach((tr, i) => {
const cell = vendorCells[i];
if (!cell) return;
const orig = tr.dataset.vendorOrig;
if (orig !== '') {
const v = parseFloat(orig);
cell.textContent = formatCurrency(v);
cell.classList.remove('text-blue-700', 'text-gray-400');
total += v;
hasAny = true;
} else {
cell.textContent = '—';
cell.classList.add('text-gray-400');
cell.classList.remove('text-blue-700');
}
});
document.getElementById('pricing-total-vendor').textContent = hasAny ? formatCurrency(total) : '—';
document.getElementById('pricing-custom-price').value = hasAny ? total.toFixed(2) : '';
syncPricingLinkedInputs('price');
// Update discount info only
const rows2 = document.querySelectorAll('#pricing-table-body tr.pricing-row');
let estimateTotal = 0;
rows2.forEach(tr => { estimateTotal += parseFloat(tr.dataset.est) || 0; });
const discountEl = document.getElementById('pricing-discount-info');
const pctEl = document.getElementById('pricing-discount-pct');
if (hasAny && total > 0 && estimateTotal > 0) {
pctEl.textContent = ((estimateTotal - total) / estimateTotal * 100).toFixed(1) + '%';
discountEl.classList.remove('hidden');
} else {
discountEl.classList.add('hidden');
}
}
function getPricingEstimateTotal() {
const rows = document.querySelectorAll('#pricing-table-body tr.pricing-row');
let estimateTotal = 0;
rows.forEach(tr => { estimateTotal += parseFloat(tr.dataset.est) || 0; });
return estimateTotal;
}
function parseDecimalInput(raw) {
if (raw == null) return 0;
const cleaned = String(raw).trim().replace(/\s/g, '').replace(',', '.');
const n = parseFloat(cleaned);
return Number.isFinite(n) ? n : 0;
}
function formatUpliftInput(value) {
if (!Number.isFinite(value) || value <= 0) return '';
return value.toFixed(4).replace('.', ',');
}
function syncPricingLinkedInputs(source) {
const customPriceInput = document.getElementById('pricing-custom-price');
const upliftInput = document.getElementById('pricing-uplift');
if (!customPriceInput || !upliftInput) return;
const estimateTotal = getPricingEstimateTotal();
if (estimateTotal <= 0) {
upliftInput.value = '';
return;
}
if (source === 'price') {
const customPrice = parseFloat(customPriceInput.value) || 0;
upliftInput.value = customPrice > 0 ? formatUpliftInput(customPrice / estimateTotal) : '';
return;
}
if (source === 'uplift') {
const uplift = parseDecimalInput(upliftInput.value);
customPriceInput.value = uplift > 0 ? (estimateTotal * uplift).toFixed(2) : '';
}
}
function onPricingUpliftInput() {
syncPricingLinkedInputs('uplift');
const customPrice = parseFloat(document.getElementById('pricing-custom-price').value) || 0;
applyPricingCustomPrice(customPrice);
}
function onPricingCustomPriceInput() {
syncPricingLinkedInputs('price');
const customPrice = parseFloat(document.getElementById('pricing-custom-price').value) || 0;
applyPricingCustomPrice(customPrice);
}
function applyPricingCustomPrice(customPrice) {
const estimateTotal = getPricingEstimateTotal();
const rows = document.querySelectorAll('#pricing-table-body tr.pricing-row');
const vendorCells = document.querySelectorAll('#pricing-table-body .pricing-vendor-price');
const totalVendorEl = document.getElementById('pricing-total-vendor');
if (customPrice > 0 && estimateTotal > 0) {
// Proportionally redistribute custom price → Цена проектная cells
let assigned = 0;
rows.forEach((tr, i) => {
const est = parseFloat(tr.dataset.est) || 0;
const cell = vendorCells[i];
if (!cell) return;
let share;
if (i === rows.length - 1) {
share = customPrice - assigned;
} else {
share = Math.round((est / estimateTotal) * customPrice * 100) / 100;
assigned += share;
}
cell.textContent = formatCurrency(share);
cell.classList.add('text-blue-700');
cell.classList.remove('text-gray-400');
});
totalVendorEl.textContent = formatCurrency(customPrice);
} else {
// Restore original vendor prices from BOM
rows.forEach((tr, i) => {
const cell = vendorCells[i];
if (!cell) return;
const orig = tr.dataset.vendorOrig;
if (orig !== '') {
cell.textContent = formatCurrency(parseFloat(orig));
cell.classList.remove('text-blue-700', 'text-gray-400');
} else {
cell.textContent = '—';
cell.classList.add('text-gray-400');
cell.classList.remove('text-blue-700');
}
});
// Recompute vendor total from originals
let origTotal = 0; let hasOrig = false;
rows.forEach(tr => { if (tr.dataset.vendorOrig !== '') { origTotal += parseFloat(tr.dataset.vendorOrig) || 0; hasOrig = true; } });
totalVendorEl.textContent = hasOrig ? formatCurrency(origTotal) : '—';
}
// Discount info
const discountEl = document.getElementById('pricing-discount-info');
const pctEl = document.getElementById('pricing-discount-pct');
if (customPrice > 0 && estimateTotal > 0) {
const discount = ((estimateTotal - customPrice) / estimateTotal * 100).toFixed(1);
pctEl.textContent = discount + '%';
discountEl.classList.remove('hidden');
} else {
discountEl.classList.add('hidden');
}
}
function exportPricingCSV() {
const rows = document.querySelectorAll('#pricing-table-body tr.pricing-row');
if (!rows.length) { showToast('Нет данных для экспорта', 'error'); return; }
const csvEscape = v => {
if (v == null) return '';
const s = String(v).replace(/"/g, '""');
return /[,"\n]/.test(s) ? `"${s}"` : s;
};
const headers = ['Lot', 'P/N вендора', 'Описание', 'Кол-во', 'Цена проектная'];
const lines = [headers.map(csvEscape).join(',')];
rows.forEach(tr => {
const cells = tr.querySelectorAll('td');
const lot = cells[0] ? cells[0].textContent.trim() : '';
const vendorPN = cells[1] ? cells[1].textContent.trim() : '';
const description = cells[2] ? cells[2].textContent.trim() : '';
const qty = cells[3] ? cells[3].textContent.trim() : '';
const vendorPrice = cells[5] ? cells[5].textContent.trim() : '';
lines.push([lot, vendorPN, description, qty, vendorPrice].map(csvEscape).join(','));
});
// Totals row
const vendorTotal = document.getElementById('pricing-total-vendor').textContent.trim();
lines.push(['', '', '', 'Итого:', vendorTotal].map(csvEscape).join(','));
const blob = new Blob(['\uFEFF' + lines.join('\r\n')], {type: 'text/csv;charset=utf-8;'});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
const today = new Date();
const yyyy = today.getFullYear();
const mm = String(today.getMonth() + 1).padStart(2, '0');
const dd = String(today.getDate()).padStart(2, '0');
const datePart = `${yyyy}-${mm}-${dd}`;
const codePart = (projectCode || 'NO-PROJECT').trim();
const namePart = (configName || 'config').trim();
a.download = `${datePart} (${codePart}) ${namePart} SPEC.csv`;
a.click();
URL.revokeObjectURL(url);
}
function escapeHtml(str) {
if (!str) return '';
return str.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function formatCurrency(val) {
if (val == null) return '—';
return val.toLocaleString('ru-RU', {minimumFractionDigits: 2, maximumFractionDigits: 2});
}
</script>
{{end}}
{{template "base" .}}