Files
QuoteForge/web/templates/index.html
Mikhail Chusavitin 7a628deb8a feat: add СХД configuration type with storage-specific tabs and LOT catalog guide
- Add config_type field ("server"|"storage") to Configuration and LocalConfiguration
- Create modal: Сервер/СХД segmented control in configs.html and project_detail.html
- Configurator: ENC/DKC/CTL categories in Base tab, HIC section in PCI tab hidden for server configs
- Add SW tab (categories: SW) to configurator, visible only when components present
- TAB_CONFIG.pci: add HIC section for storage HIC adapters (separate from server HBA/NIC)
- Migration 029: ALTER TABLE qt_configurations ADD COLUMN config_type
- Fix: skip Error 1833 (Cannot change column used in FK) in GORM AutoMigrate
- Operator guide: docs/storage-components-guide.md with LOT naming rules and DE4000H catalog template

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 18:01:23 +03:00

4134 lines
172 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"}}OFS - Конфигуратор{{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">main</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('sw')" data-tab="sw"
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">
SW
</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 space-y-6">
<!-- === Цена покупки === -->
<div class="bg-white rounded-lg shadow p-4">
<div class="flex items-baseline gap-3 mb-3">
<h3 class="text-base font-semibold text-gray-800">Цена покупки</h3>
<span class="text-xs text-gray-400">Цены указаны за 1 шт.</span>
</div>
<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">PN вендора</th>
<th class="px-3 py-2 text-left border-b">Описание</th>
<th class="px-3 py-2 text-left border-b">LOT</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-body-buy">
<tr><td colspan="8" class="px-3 py-8 text-center text-gray-400">Загрузите BOM во вкладке «BOM»</td></tr>
</tbody>
<tfoot id="pricing-foot-buy" 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-buy-estimate"></td>
<td class="px-3 py-2 text-right" id="pricing-total-buy-warehouse"></td>
<td class="px-3 py-2 text-right" id="pricing-total-buy-competitor"></td>
<td class="px-3 py-2 text-right font-bold" id="pricing-total-buy-vendor"></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-buy" 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="onBuyCustomPriceInput()">
<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('buy')" class="px-3 py-2 bg-green-600 text-white rounded hover:bg-green-700 text-sm">
Экспорт CSV
</button>
</div>
</div>
<!-- === Цена продажи === -->
<div class="bg-white rounded-lg shadow p-4">
<div class="flex items-baseline gap-3 mb-1">
<h3 class="text-base font-semibold text-gray-800">Цена продажи</h3>
<span class="text-xs text-gray-400">Цены указаны за 1 шт.</span>
</div>
<p class="text-xs text-gray-500 mb-3">Склад и Конкуренты умножаются на 1,3</p>
<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">PN вендора</th>
<th class="px-3 py-2 text-left border-b">Описание</th>
<th class="px-3 py-2 text-left border-b">LOT</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-body-sale">
<tr><td colspan="8" class="px-3 py-8 text-center text-gray-400">Загрузите BOM во вкладке «BOM»</td></tr>
</tbody>
<tfoot id="pricing-foot-sale" 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-sale-estimate"></td>
<td class="px-3 py-2 text-right" id="pricing-total-sale-warehouse"></td>
<td class="px-3 py-2 text-right" id="pricing-total-sale-competitor"></td>
<td class="px-3 py-2 text-right font-bold" id="pricing-total-sale-vendor"></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">Аплифт к estimate:</label>
<input type="text" id="pricing-uplift-sale" inputmode="decimal" placeholder="1,3000"
class="w-28 px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500"
oninput="onSaleMarkupInput()">
<label class="text-sm font-medium text-gray-700">Ручная цена:</label>
<input type="number" id="pricing-custom-price-sale" 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="onSaleCustomPriceInput()">
<button onclick="exportPricingCSV('sale')" class="px-3 py-2 bg-green-600 text-white rounded hover:bg-green-700 text-sm">
Экспорт CSV
</button>
</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', 'ENC', 'DKC', 'CTL'],
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', 'HIC'],
singleSelect: false,
label: 'PCI',
sections: [
{ title: 'GPU / DPU', categories: ['GPU', 'DPU'] },
{ title: 'NIC / HCA', categories: ['NIC', 'HCA'] },
{ title: 'HBA', categories: ['HBA'] },
{ title: 'HIC', categories: ['HIC'] }
]
},
power: {
categories: ['PS', 'PSU'],
singleSelect: false,
label: 'Power'
},
accessories: {
categories: ['ACC', 'CARD'],
singleSelect: false,
label: 'Accessories'
},
sw: {
categories: ['SW'],
singleSelect: false,
label: 'SW'
},
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 configType = 'server';
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 = 'main';
document.title = code + ' / ' + variant + ' / ' + fullConfigName + ' — OFS';
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;
configType = config.config_type || 'server';
applyConfigTypeToTabs();
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 || [];
// Do not reset the stored pricelist — it may be inactive but must be preserved
} catch (e) {
activePricelistsBySource[source] = [];
}
}));
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 = pricelists.map(pl => {
return `<option value="${pl.id}">${escapeHtml(pl.version)}</option>`;
}).join('');
const current = selectedPricelistIds[source];
if (current) {
select.value = String(current);
// Stored pricelist may be inactive — add it as a virtual option if not found
if (!select.value) {
const opt = document.createElement('option');
opt.value = String(current);
opt.textContent = `ID ${current} (неактивный)`;
select.prepend(opt);
select.value = String(current);
}
} else if (pricelists.length > 0) {
// New config: pre-select the first (latest) pricelist
selectedPricelistIds[source] = Number(pricelists[0].id);
select.value = String(pricelists[0].id);
}
}
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();
if (Number.isFinite(estimateVal) && estimateVal > 0) {
selectedPricelistIds.estimate = estimateVal;
resolvedAutoPricelistIds.estimate = null;
}
if (Number.isFinite(warehouseVal) && warehouseVal > 0) {
selectedPricelistIds.warehouse = warehouseVal;
resolvedAutoPricelistIds.warehouse = null;
}
if (Number.isFinite(competitorVal) && competitorVal > 0) {
selectedPricelistIds.competitor = competitorVal;
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();
}
const ALWAYS_VISIBLE_TABS = new Set(['base', 'storage', 'pci']);
// Storage-only categories — hidden for server configs
const STORAGE_ONLY_BASE_CATEGORIES = ['ENC', 'DKC', 'CTL'];
function applyConfigTypeToTabs() {
if (configType === 'storage') return; // storage sees everything
// Remove ENC/DKC/CTL from Base
TAB_CONFIG.base.categories = TAB_CONFIG.base.categories.filter(
c => !STORAGE_ONLY_BASE_CATEGORIES.includes(c)
);
// Remove HIC from PCI tab
TAB_CONFIG.pci.categories = TAB_CONFIG.pci.categories.filter(c => c !== 'HIC');
TAB_CONFIG.pci.sections = TAB_CONFIG.pci.sections.filter(s => s.title !== 'HIC');
// Rebuild assigned categories index
ASSIGNED_CATEGORIES = Object.values(TAB_CONFIG)
.flatMap(t => t.categories)
.map(c => c.toUpperCase());
}
function updateTabVisibility() {
for (const tabId of Object.keys(TAB_CONFIG)) {
if (ALWAYS_VISIBLE_TABS.has(tabId)) continue;
const btn = document.querySelector(`[data-tab="${tabId}"]`);
if (!btn) continue;
const hasComponents = getComponentsForTab(tabId).length > 0;
const hasCartItems = cart.some(item => {
const cat = (item.category || getCategoryFromLotName(item.lot_name) || '').toUpperCase();
return getTabForCategory(cat) === tabId;
});
const visible = hasComponents || hasCartItems;
btn.classList.toggle('hidden', !visible);
// If the current tab just got hidden, fall back to base
if (!visible && currentTab === tabId) {
switchTab('base');
}
}
}
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);
updateTabVisibility();
renderTab();
updateCartUI();
triggerAutoSave();
}
function updateCartUI() {
updateTabVisibility();
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;
}
updateConfigBreadcrumbs();
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 refreshPayload = {};
if (selectedPricelistIds.estimate) {
refreshPayload.pricelist_id = selectedPricelistIds.estimate;
}
const resp = await fetch('/api/configs/' + configUUID + '/refresh-prices', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(refreshPayload)
});
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({ force: true, noCache: true });
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 tbodyBuy = document.getElementById('pricing-body-buy');
const tfootBuy = document.getElementById('pricing-foot-buy');
const tbodySale = document.getElementById('pricing-body-sale');
const tfootSale = document.getElementById('pricing-foot-sale');
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 });
}
});
}
});
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 = {};
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 */ }
}
// Sale uplift applied to estimate (default 1.3)
const saleUplift = (() => {
const v = parseDecimalInput(document.getElementById('pricing-uplift-sale')?.value || '');
return v > 0 ? v : 1.3;
})();
const SALE_FIXED_MULT = 1.3;
// Helper: returns unit prices from pricelist for a single LOT
const _getUnitPrices = (pl) => ({
estUnit: (pl && pl.estimate_price > 0) ? pl.estimate_price : 0,
warehouseUnit: (pl && pl.warehouse_price > 0) ? pl.warehouse_price : null,
competitorUnit: (pl && pl.competitor_price > 0) ? pl.competitor_price : null,
});
// ─── Build shared row data (unit prices for display, totals for math) ────
// Each BOM row is exploded into per-LOT sub-rows; grouped by vendor PN via groupStart/groupSize.
const _buildRows = () => {
const result = [];
const coveredLots = new Set();
const _pushCartRow = (item, isEstOnly) => {
const pl = priceMap[item.lot_name];
const u = _getUnitPrices(pl);
const estUnit = u.estUnit > 0 ? u.estUnit : (item.unit_price || 0);
result.push({
lotCell: escapeHtml(item.lot_name), lotText: item.lot_name,
vendorPN: null,
desc: (compMap[item.lot_name] || {}).description || '',
qty: item.quantity,
estUnit, warehouseUnit: u.warehouseUnit, competitorUnit: u.competitorUnit,
est: estUnit * item.quantity,
warehouse: u.warehouseUnit != null ? u.warehouseUnit * item.quantity : null,
competitor: u.competitorUnit != null ? u.competitorUnit * item.quantity : null,
vendorOrig: null, vendorOrigUnit: null, isEstOnly,
groupStart: true, groupSize: 1,
});
};
if (!bomRows.length) {
cart.forEach(item => { _pushCartRow(item, false); coveredLots.add(item.lot_name); });
return { result, coveredLots };
}
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) coveredLots.add(baseLot);
allocs.forEach(a => coveredLots.add(a.lot_name));
const vendorOrigUnit = row.unit_price != null ? row.unit_price
: (row.total_price != null && row.quantity > 0 ? row.total_price / row.quantity : null);
const vendorOrig = row.total_price != null ? row.total_price
: (row.unit_price != null ? row.unit_price * row.quantity : null);
const desc = row.description || (baseLot ? ((compMap[baseLot] || {}).description || '') : '');
// Build per-LOT sub-rows
const subRows = [];
if (baseLot) {
const u = _getUnitPrices(priceMap[baseLot]);
const lotQty = _getRowLotQtyPerPN(row);
const qty = row.quantity * lotQty;
subRows.push({
lotCell: escapeHtml(baseLot), lotText: baseLot, qty,
estUnit: u.estUnit > 0 ? u.estUnit : 0,
warehouseUnit: u.warehouseUnit, competitorUnit: u.competitorUnit,
est: u.estUnit > 0 ? u.estUnit * qty : 0,
warehouse: u.warehouseUnit != null ? u.warehouseUnit * qty : null,
competitor: u.competitorUnit != null ? u.competitorUnit * qty : null,
});
}
allocs.forEach(a => {
const u = _getUnitPrices(priceMap[a.lot_name]);
const qty = row.quantity * a.quantity;
subRows.push({
lotCell: escapeHtml(a.lot_name), lotText: a.lot_name, qty,
estUnit: u.estUnit > 0 ? u.estUnit : 0,
warehouseUnit: u.warehouseUnit, competitorUnit: u.competitorUnit,
est: u.estUnit > 0 ? u.estUnit * qty : 0,
warehouse: u.warehouseUnit != null ? u.warehouseUnit * qty : null,
competitor: u.competitorUnit != null ? u.competitorUnit * qty : null,
});
});
if (!subRows.length) {
result.push({
lotCell: '<span class="text-red-500">н/д</span>', lotText: '',
vendorPN: row.vendor_pn, desc, qty: row.quantity,
estUnit: 0, warehouseUnit: null, competitorUnit: null,
est: 0, warehouse: null, competitor: null,
vendorOrig, vendorOrigUnit, isEstOnly: false,
groupStart: true, groupSize: 1,
});
return;
}
const groupSize = subRows.length;
subRows.forEach((sub, idx) => {
result.push({
lotCell: sub.lotCell, lotText: sub.lotText,
vendorPN: row.vendor_pn, desc,
qty: sub.qty,
estUnit: sub.estUnit, warehouseUnit: sub.warehouseUnit, competitorUnit: sub.competitorUnit,
est: sub.est, warehouse: sub.warehouse, competitor: sub.competitor,
vendorOrig: idx === 0 ? vendorOrig : null,
vendorOrigUnit: idx === 0 ? vendorOrigUnit : null,
isEstOnly: false,
groupStart: idx === 0,
groupSize: idx === 0 ? groupSize : 0,
});
});
});
// Estimate-only LOTs (cart items not covered by BOM)
cart.forEach(item => {
if (!item?.lot_name || coveredLots.has(item.lot_name)) return;
_pushCartRow(item, true);
coveredLots.add(item.lot_name);
});
return { result, coveredLots };
};
const { result: rowData } = _buildRows();
// ─── Populate Buy table ──────────────────────────────────────────────────
tbodyBuy.innerHTML = '';
if (!rowData.length) {
tbodyBuy.innerHTML = '<tr><td colspan="8" class="px-3 py-8 text-center text-gray-400">Нет данных для отображения</td></tr>';
tfootBuy.classList.add('hidden');
} else {
let totEst = 0, totWh = 0, totComp = 0, totVendor = 0;
let hasEst = false, hasWh = false, hasComp = false, hasVendor = false;
let cntWh = 0, cntComp = 0;
rowData.forEach(r => {
const tr = document.createElement('tr');
tr.classList.add('pricing-row-buy');
if (r.isEstOnly) tr.classList.add('bg-blue-50');
tr.dataset.est = r.est;
tr.dataset.qty = r.qty;
tr.dataset.vendorOrig = r.vendorOrig != null ? r.vendorOrig : '';
tr.dataset.vendorOrigUnit = r.vendorOrigUnit != null ? r.vendorOrigUnit : '';
tr.dataset.groupStart = r.groupStart ? 'true' : 'false';
tr.dataset.vendorPn = r.vendorPN || '';
tr.dataset.desc = r.desc;
tr.dataset.lot = r.lotText;
if (r.est > 0) { totEst += r.est; hasEst = true; }
if (r.warehouse != null) { totWh += r.warehouse; hasWh = true; cntWh++; }
if (r.competitor != null) { totComp += r.competitor; hasComp = true; cntComp++; }
if (r.vendorOrig != null) { totVendor += r.vendorOrig; hasVendor = true; }
const borderTop = r.groupStart ? 'border-t border-gray-200' : '';
const pnDescHtml = r.groupStart ? (() => {
const rs = r.groupSize > 1 ? ` rowspan="${r.groupSize}"` : '';
return `<td${rs} class="px-3 py-1.5 font-mono text-xs border-t border-gray-200 align-top ${r.vendorPN == null ? 'text-gray-400' : ''}">${r.vendorPN != null ? escapeHtml(r.vendorPN) : '—'}</td>
<td${rs} class="px-3 py-1.5 text-xs text-gray-500 truncate max-w-xs border-t border-gray-200 align-top">${escapeHtml(r.desc)}</td>`;
})() : '';
tr.innerHTML = `
${pnDescHtml}
<td class="px-3 py-1.5 text-xs ${borderTop}">${r.lotCell}</td>
<td class="px-3 py-1.5 text-right text-xs ${borderTop}">${r.qty}</td>
<td class="px-3 py-1.5 text-right text-xs ${borderTop}">${r.estUnit > 0 ? formatCurrency(r.estUnit) : '—'}</td>
<td class="px-3 py-1.5 text-right text-xs ${borderTop}">${r.warehouseUnit != null ? formatCurrency(r.warehouseUnit) : '—'}</td>
<td class="px-3 py-1.5 text-right text-xs ${borderTop}">${r.competitorUnit != null ? formatCurrency(r.competitorUnit) : '—'}</td>
<td class="px-3 py-1.5 text-right text-xs pricing-vendor-price-buy ${borderTop} ${r.vendorOrigUnit == null ? 'text-gray-400' : ''}">${r.vendorOrigUnit != null ? formatCurrency(r.vendorOrigUnit) : '—'}</td>
`;
tbodyBuy.appendChild(tr);
});
document.getElementById('pricing-total-buy-estimate').textContent = hasEst ? formatCurrency(totEst) : '—';
document.getElementById('pricing-total-buy-vendor').textContent = hasVendor ? formatCurrency(totVendor) : '—';
_setPartialTotal('pricing-total-buy-warehouse', hasWh, totWh, cntWh, rowData.length);
_setPartialTotal('pricing-total-buy-competitor', hasComp, totComp, cntComp, rowData.length);
tfootBuy.classList.remove('hidden');
}
// ─── Populate Sale table ─────────────────────────────────────────────────
tbodySale.innerHTML = '';
if (!rowData.length) {
tbodySale.innerHTML = '<tr><td colspan="8" class="px-3 py-8 text-center text-gray-400">Нет данных для отображения</td></tr>';
tfootSale.classList.add('hidden');
} else {
let totEst = 0, totWh = 0, totComp = 0;
let hasEst = false, hasWh = false, hasComp = false;
let cntWh = 0, cntComp = 0;
rowData.forEach(r => {
const tr = document.createElement('tr');
tr.classList.add('pricing-row-sale');
if (r.isEstOnly) tr.classList.add('bg-blue-50');
const saleEstUnit = r.estUnit > 0 ? r.estUnit * saleUplift : 0;
const saleWhUnit = r.warehouseUnit != null ? r.warehouseUnit * SALE_FIXED_MULT : null;
const saleCompUnit = r.competitorUnit != null ? r.competitorUnit * SALE_FIXED_MULT : null;
const saleEstTotal = saleEstUnit * r.qty;
const saleWhTotal = saleWhUnit != null ? saleWhUnit * r.qty : null;
const saleCompTotal = saleCompUnit != null ? saleCompUnit * r.qty : null;
tr.dataset.estSale = saleEstTotal;
tr.dataset.qty = r.qty;
tr.dataset.groupStart = r.groupStart ? 'true' : 'false';
tr.dataset.vendorPn = r.vendorPN || '';
tr.dataset.desc = r.desc;
tr.dataset.lot = r.lotText;
if (saleEstTotal > 0) { totEst += saleEstTotal; hasEst = true; }
if (saleWhTotal != null) { totWh += saleWhTotal; hasWh = true; cntWh++; }
if (saleCompTotal != null) { totComp += saleCompTotal; hasComp = true; cntComp++; }
const borderTop = r.groupStart ? 'border-t border-gray-200' : '';
const pnDescHtml = r.groupStart ? (() => {
const rs = r.groupSize > 1 ? ` rowspan="${r.groupSize}"` : '';
return `<td${rs} class="px-3 py-1.5 font-mono text-xs border-t border-gray-200 align-top ${r.vendorPN == null ? 'text-gray-400' : ''}">${r.vendorPN != null ? escapeHtml(r.vendorPN) : '—'}</td>
<td${rs} class="px-3 py-1.5 text-xs text-gray-500 truncate max-w-xs border-t border-gray-200 align-top">${escapeHtml(r.desc)}</td>`;
})() : '';
tr.innerHTML = `
${pnDescHtml}
<td class="px-3 py-1.5 text-xs ${borderTop}">${r.lotCell}</td>
<td class="px-3 py-1.5 text-right text-xs ${borderTop}">${r.qty}</td>
<td class="px-3 py-1.5 text-right text-xs ${borderTop}">${saleEstUnit > 0 ? formatCurrency(saleEstUnit) : '—'}</td>
<td class="px-3 py-1.5 text-right text-xs ${borderTop}">${saleWhUnit != null ? formatCurrency(saleWhUnit) : '—'}</td>
<td class="px-3 py-1.5 text-right text-xs ${borderTop}">${saleCompUnit != null ? formatCurrency(saleCompUnit) : '—'}</td>
<td class="px-3 py-1.5 text-right text-xs pricing-vendor-price-sale ${borderTop} text-gray-400">—</td>
`;
tbodySale.appendChild(tr);
});
document.getElementById('pricing-total-sale-estimate').textContent = hasEst ? formatCurrency(totEst) : '—';
document.getElementById('pricing-total-sale-vendor').textContent = '—';
_setPartialTotal('pricing-total-sale-warehouse', hasWh, totWh, cntWh, rowData.length);
_setPartialTotal('pricing-total-sale-competitor', hasComp, totComp, cntComp, rowData.length);
tfootSale.classList.remove('hidden');
}
// Restore custom prices after re-render
applyCustomPrice('buy');
applyCustomPrice('sale');
}
// ─── Pricing helpers ─────────────────────────────────────────────────────────
// Sets a footer total cell. If has prices but coverage < totalRows, marks red with a hover asterisk.
function _setPartialTotal(elId, has, total, count, totalRows) {
const el = document.getElementById(elId);
if (!el) return;
el.className = el.className.replace(/\btext-red-\d+\b/g, '').trim();
if (!has) { el.textContent = '—'; return; }
if (count < totalRows) {
el.innerHTML = `<span class="text-red-600">${formatCurrency(total)}</span> <span class="text-red-400 cursor-help" title="Цены указаны не для всех позиций (${count} из ${totalRows})">*</span>`;
} else {
el.textContent = formatCurrency(total);
}
}
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 _getPricingEstimateTotal(table) {
const attr = table === 'sale' ? 'estSale' : 'est';
const cls = table === 'sale' ? 'pricing-row-sale' : 'pricing-row-buy';
let total = 0;
document.querySelectorAll(`#pricing-body-${table} tr.${cls}`).forEach(tr => {
total += parseFloat(tr.dataset[attr]) || 0;
});
return total;
}
// Apply custom (own) price proportionally to Ручная цена column.
// table: 'buy' | 'sale'
function applyCustomPrice(table) {
const inputId = `pricing-custom-price-${table}`;
const totalElId = `pricing-total-${table}-vendor`;
const rowClass = `pricing-row-${table}`;
const cellClass = `.pricing-vendor-price-${table}`;
const estAttr = table === 'sale' ? 'estSale' : 'est';
const origAttr = table === 'buy' ? 'vendorOrig' : null;
const customPrice = parseFloat(document.getElementById(inputId)?.value) || 0;
const estimateTotal = _getPricingEstimateTotal(table);
const rows = document.querySelectorAll(`#pricing-body-${table} tr.${rowClass}`);
const vendorCells = document.querySelectorAll(`#pricing-body-${table} ${cellClass}`);
const totalVendorEl = document.getElementById(totalElId);
const _pctLabel = (custom, est) => {
if (est <= 0) return '';
const pct = ((est - custom) / est * 100);
const sign = pct >= 0 ? '-' : '+';
return ` (${sign}${Math.abs(pct).toFixed(1)}%)`;
};
const _pctClass = (custom, est) => custom <= est ? 'text-green-600' : 'text-red-600';
if (customPrice > 0 && estimateTotal > 0) {
let assigned = 0;
rows.forEach((tr, i) => {
const rowEst = parseFloat(tr.dataset[estAttr]) || 0;
const qty = Math.max(1, parseFloat(tr.dataset.qty) || 1);
const cell = vendorCells[i];
if (!cell) return;
let share;
if (i === rows.length - 1) {
share = customPrice - assigned;
} else {
share = Math.round((rowEst / estimateTotal) * customPrice * 100) / 100;
assigned += share;
}
cell.textContent = formatCurrency(share / qty);
cell.className = cell.className.replace(/\btext-(?:gray|green|red|blue)-\d+\b/g, '').trim();
cell.classList.add(rowEst > 0 ? _pctClass(share, rowEst) : 'text-blue-700');
});
const pctStr = _pctLabel(customPrice, estimateTotal);
totalVendorEl.textContent = formatCurrency(customPrice) + pctStr;
totalVendorEl.className = totalVendorEl.className.replace(/\btext-(?:green|red)-\d+\b/g, '').trim();
totalVendorEl.classList.add(_pctClass(customPrice, estimateTotal));
} else {
// Restore originals
rows.forEach((tr, i) => {
const cell = vendorCells[i];
if (!cell) return;
cell.className = cell.className.replace(/\btext-(?:gray|green|red|blue)-\d+\b/g, '').trim();
if (origAttr && tr.dataset.vendorOrigUnit !== '') {
cell.textContent = formatCurrency(parseFloat(tr.dataset.vendorOrigUnit));
} else {
cell.textContent = '—';
cell.classList.add('text-gray-400');
}
});
// Recompute total from originals (buy) or clear (sale)
if (origAttr) {
let origTotal = 0; let hasOrig = false;
rows.forEach(tr => { if (tr.dataset[origAttr] !== '') { origTotal += parseFloat(tr.dataset[origAttr]) || 0; hasOrig = true; } });
totalVendorEl.textContent = hasOrig ? formatCurrency(origTotal) : '—';
} else {
// sale: reset to — already handled above
totalVendorEl.textContent = '—';
}
totalVendorEl.className = totalVendorEl.className.replace(/\btext-(?:green|red)-\d+\b/g, '').trim();
}
}
function onBuyCustomPriceInput() {
applyCustomPrice('buy');
}
function onSaleCustomPriceInput() {
applyCustomPrice('sale');
}
function onSaleMarkupInput() {
renderPricingTab();
}
function setPricingCustomPriceFromVendor() {
// Fill Ручная цена in Buy table from BOM vendor totals
const rows = document.querySelectorAll('#pricing-body-buy tr.pricing-row-buy');
const vendorCells = document.querySelectorAll('#pricing-body-buy .pricing-vendor-price-buy');
let total = 0;
let hasAny = false;
rows.forEach((tr, i) => {
const cell = vendorCells[i];
if (!cell) return;
const origUnit = tr.dataset.vendorOrigUnit;
const origTotal = tr.dataset.vendorOrig;
if (origUnit !== '') {
cell.textContent = formatCurrency(parseFloat(origUnit));
cell.className = cell.className.replace(/\btext-(?:gray|green|red|blue)-\d+\b/g, '').trim();
total += parseFloat(origTotal) || 0;
hasAny = true;
} else {
cell.textContent = '—';
cell.className = cell.className.replace(/\btext-(?:green|red|blue)-\d+\b/g, '').trim();
cell.classList.add('text-gray-400');
}
});
const estimateTotal = _getPricingEstimateTotal('buy');
const totalEl = document.getElementById('pricing-total-buy-vendor');
if (hasAny) {
document.getElementById('pricing-custom-price-buy').value = total.toFixed(2);
const pct = estimateTotal > 0 ? ` (-${((estimateTotal - total) / estimateTotal * 100).toFixed(1)}%)` : '';
totalEl.textContent = formatCurrency(total) + pct;
totalEl.className = totalEl.className.replace(/\btext-(?:green|red)-\d+\b/g, '').trim();
totalEl.classList.add(total <= estimateTotal ? 'text-green-600' : 'text-red-600');
} else {
document.getElementById('pricing-custom-price-buy').value = '';
totalEl.textContent = '—';
}
}
function exportPricingCSV(table) {
const bodyId = table === 'sale' ? 'pricing-body-sale' : 'pricing-body-buy';
const rowClass = table === 'sale' ? 'pricing-row-sale' : 'pricing-row-buy';
const totalIds = table === 'sale'
? { est: 'pricing-total-sale-estimate', wh: 'pricing-total-sale-warehouse', comp: 'pricing-total-sale-competitor', vendor: 'pricing-total-sale-vendor' }
: { est: 'pricing-total-buy-estimate', wh: 'pricing-total-buy-warehouse', comp: 'pricing-total-buy-competitor', vendor: 'pricing-total-buy-vendor' };
const rows = document.querySelectorAll(`#${bodyId} tr.${rowClass}`);
if (!rows.length) { showToast('Нет данных для экспорта', 'error'); return; }
const csvDelimiter = ';';
const cleanExportCell = value => {
const text = String(value || '').replace(/\s+/g, ' ').trim();
if (!text || text === '—') return text || '';
return text
.replace(/\s*\(.*\)$/, '')
.replace(/\s*\*+\s*$/, '')
.trim();
};
const csvEscape = v => {
if (v == null) return '';
const s = String(v).replace(/"/g, '""');
return /[;"\n\r]/.test(s) ? `"${s}"` : s;
};
const headers = ['PN вендора', 'Описание', 'LOT', 'Кол-во', 'Estimate', 'Склад', 'Конкуренты', 'Ручная цена'];
const lines = [headers.map(csvEscape).join(csvDelimiter)];
rows.forEach(tr => {
// PN вендора, Описание, LOT are stored in dataset to handle rowspan correctly
const pn = cleanExportCell(tr.dataset.vendorPn || '');
const desc = cleanExportCell(tr.dataset.desc || '');
const lot = cleanExportCell(tr.dataset.lot || '');
// Qty..Ручная цена: cells at offset 2 for group-start rows, offset 0 for sub-rows
const isGroupStart = tr.dataset.groupStart === 'true';
const cells = tr.querySelectorAll('td');
const o = isGroupStart ? 2 : 0;
const cols = [pn, desc, lot,
cleanExportCell(cells[o]?.textContent),
cleanExportCell(cells[o+1]?.textContent),
cleanExportCell(cells[o+2]?.textContent),
cleanExportCell(cells[o+3]?.textContent),
cleanExportCell(cells[o+4]?.textContent),
];
lines.push(cols.map(csvEscape).join(csvDelimiter));
});
// Totals row
const tEst = cleanExportCell(document.getElementById(totalIds.est)?.textContent);
const tWh = cleanExportCell(document.getElementById(totalIds.wh)?.textContent);
const tComp = cleanExportCell(document.getElementById(totalIds.comp)?.textContent);
const tVendor = cleanExportCell(document.getElementById(totalIds.vendor)?.textContent);
lines.push(['', '', '', 'Итого:', tEst, tWh, tComp, tVendor].map(csvEscape).join(csvDelimiter));
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 datePart = `${today.getFullYear()}-${String(today.getMonth()+1).padStart(2,'0')}-${String(today.getDate()).padStart(2,'0')}`;
const codePart = (projectCode || 'NO-PROJECT').trim();
const namePart = (configName || 'config').trim();
const suffix = table === 'sale' ? 'SALE' : 'BUY';
a.download = `${datePart} (${codePart}) ${namePart} SPEC-${suffix}.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" .}}