- BOM paste: auto-detect columns by content (price, qty, PN, description); handles $5,114.00 and European comma-decimal formats - LOT input: HTML5 datalist rebuilt on each renderBOMTable from allComponents; oninput updates data only (no re-render), onchange validates+resolves - BOM persistence: PUT handler explicitly marshals VendorSpec to JSON string (GORM Update does not reliably call driver.Valuer for custom types) - BOM autosave after every resolveBOM() call - Pricing tab: async renderPricingTab() calls /api/quote/price-levels for all resolved LOTs directly — Estimate prices shown even before cart apply - Unresolved PNs pushed to qt_vendor_partnumber_seen via POST /api/sync/partnumber-seen (fire-and-forget from JS) - sync.PushPartnumberSeen(): upsert with ON DUPLICATE KEY UPDATE last_seen_at - partnumber_books: pull ALL books (not only is_active=1); re-pull items when header exists but item count is 0; fallback for missing description column - partnumber_books UI: collapsible snapshot section (collapsed by default), pagination (10/page), sync button always visible in header - vendorSpec handlers: use GetConfigurationByUUID + IsActive check (removed original_username from WHERE — GetUsername returns "" without JWT) - bible/09-vendor-spec.md: updated with all architectural decisions Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
3170 lines
126 KiB
HTML
3170 lines
126 KiB
HTML
{{define "title"}}QuoteForge - Конфигуратор{{end}}
|
||
|
||
{{define "content"}}
|
||
<div class="space-y-4">
|
||
<!-- Header with config name and back button -->
|
||
<div class="flex items-center justify-between">
|
||
<div class="flex items-center space-x-4">
|
||
<a href="/projects" class="text-gray-500 hover:text-gray-700" title="Все проекты">
|
||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 9.75L12 3l9 6.75v9A2.25 2.25 0 0118.75 21h-13.5A2.25 2.25 0 013 18.75v-9z"></path>
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 21v-6h6v6"></path>
|
||
</svg>
|
||
</a>
|
||
<div class="text-2xl font-bold flex items-center gap-2" id="config-breadcrumbs">
|
||
<a id="breadcrumb-project-code-link" href="/projects" class="text-blue-700 hover:underline">
|
||
<span id="breadcrumb-project-code">—</span>
|
||
</a>
|
||
<span class="text-gray-400">-</span>
|
||
<a id="breadcrumb-project-variant-link" href="/projects" class="text-blue-700 hover:underline">
|
||
<span id="breadcrumb-project-variant">main</span>
|
||
</a>
|
||
<span class="text-gray-400">-</span>
|
||
<a id="breadcrumb-config-name-link" href="#" class="text-blue-700 hover:underline">
|
||
<span id="breadcrumb-config-name">Конфигуратор</span>
|
||
</a>
|
||
<span class="text-gray-400">-</span>
|
||
<span id="breadcrumb-config-version">v1</span>
|
||
</div>
|
||
</div>
|
||
<div id="save-buttons" class="hidden flex items-center space-x-2">
|
||
<button id="refresh-prices-btn" onclick="refreshPrices()" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">
|
||
Обновить цены
|
||
</button>
|
||
<button type="button"
|
||
onclick="openPriceSettingsModal()"
|
||
class="h-10 px-3 bg-gray-100 text-gray-700 rounded hover:bg-gray-200 border border-gray-300 inline-flex items-center justify-center"
|
||
title="Настройки цен">
|
||
Цены
|
||
</button>
|
||
<button onclick="saveConfig()" class="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700">
|
||
Сохранить
|
||
</button>
|
||
<span id="price-update-date" class="text-sm text-gray-500"></span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Server count input -->
|
||
<div class="bg-white rounded-lg shadow p-4 mb-4">
|
||
<div class="flex items-center space-x-4">
|
||
<div>
|
||
<label class="block text-sm font-medium text-gray-700 mb-1">Количество серверов</label>
|
||
<input type="number" id="server-count" min="1" value="1"
|
||
class="w-20 px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500"
|
||
onchange="updateServerCount()">
|
||
</div>
|
||
<div class="text-sm text-gray-600">
|
||
<div class="font-medium text-gray-700 mb-1">Прайслисты цен</div>
|
||
<div id="pricelist-settings-summary">Estimate: авто, Склад: авто, Конкуренты: авто</div>
|
||
</div>
|
||
<div class="text-sm text-gray-500">
|
||
<span id="server-count-info">Всего: <span id="total-server-count">1</span> сервер(а)</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Top-level tabs: Estimate | BOM вендора | Ценообразование -->
|
||
<div class="bg-white rounded-lg shadow mb-0">
|
||
<nav class="flex border-b">
|
||
<button id="top-tab-estimate" onclick="switchTopTab('estimate')"
|
||
class="px-5 py-3 text-sm font-semibold border-b-2 border-blue-600 text-blue-600">
|
||
Estimate
|
||
</button>
|
||
<button id="top-tab-bom" onclick="switchTopTab('bom')"
|
||
class="px-5 py-3 text-sm font-semibold border-b-2 border-transparent text-gray-500 hover:text-gray-700">
|
||
BOM вендора
|
||
</button>
|
||
<button id="top-tab-pricing" onclick="switchTopTab('pricing')"
|
||
class="px-5 py-3 text-sm font-semibold border-b-2 border-transparent text-gray-500 hover:text-gray-700">
|
||
Ценообразование
|
||
</button>
|
||
</nav>
|
||
</div>
|
||
|
||
<!-- Top-tab section: Estimate -->
|
||
<div id="top-section-estimate">
|
||
|
||
<!-- Category Tabs -->
|
||
<div class="bg-white rounded-lg shadow">
|
||
<div class="border-b">
|
||
<nav class="flex overflow-x-auto" id="category-tabs">
|
||
<button onclick="switchTab('base')" data-tab="base"
|
||
class="tab-btn px-4 py-3 text-sm font-medium border-b-2 border-blue-600 text-blue-600 whitespace-nowrap">
|
||
Base
|
||
</button>
|
||
<button onclick="switchTab('storage')" data-tab="storage"
|
||
class="tab-btn px-4 py-3 text-sm font-medium border-b-2 border-transparent text-gray-500 hover:text-gray-700 whitespace-nowrap">
|
||
Storage
|
||
</button>
|
||
<button onclick="switchTab('pci')" data-tab="pci"
|
||
class="tab-btn px-4 py-3 text-sm font-medium border-b-2 border-transparent text-gray-500 hover:text-gray-700 whitespace-nowrap">
|
||
PCI
|
||
</button>
|
||
<button onclick="switchTab('power')" data-tab="power"
|
||
class="tab-btn px-4 py-3 text-sm font-medium border-b-2 border-transparent text-gray-500 hover:text-gray-700 whitespace-nowrap">
|
||
Power
|
||
</button>
|
||
<button onclick="switchTab('accessories')" data-tab="accessories"
|
||
class="tab-btn px-4 py-3 text-sm font-medium border-b-2 border-transparent text-gray-500 hover:text-gray-700 whitespace-nowrap">
|
||
Accessories
|
||
</button>
|
||
<button onclick="switchTab('other')" data-tab="other"
|
||
class="tab-btn px-4 py-3 text-sm font-medium border-b-2 border-transparent text-gray-500 hover:text-gray-700 whitespace-nowrap">
|
||
Other
|
||
</button>
|
||
</nav>
|
||
</div>
|
||
|
||
<!-- Tab content -->
|
||
<div id="tab-content" class="p-4">
|
||
<div class="text-center py-8 text-gray-500">Загрузка...</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Cart summary -->
|
||
<div id="cart-summary" class="bg-white rounded-lg shadow overflow-hidden">
|
||
<button type="button"
|
||
onclick="toggleCartSummarySection()"
|
||
class="w-full px-4 py-3 flex items-center justify-between text-blue-900 bg-gradient-to-r from-blue-100 to-blue-50 hover:from-blue-200 hover:to-blue-100 border-b border-blue-200">
|
||
<span class="font-semibold">Итого конфигурация</span>
|
||
<svg id="cart-summary-toggle-icon" class="w-5 h-5 transition-transform duration-200" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
||
</svg>
|
||
</button>
|
||
<div id="cart-summary-content" class="p-4">
|
||
<div id="article-display" class="text-sm text-gray-700 mb-3 font-mono"></div>
|
||
<div id="cart-items" class="space-y-2 mb-4"></div>
|
||
<div class="border-t pt-3 flex justify-between items-center">
|
||
<div class="text-lg font-bold">
|
||
Итого: <span id="cart-total">$0.00</span>
|
||
</div>
|
||
<button onclick="exportCSV()" class="px-3 py-1 bg-gray-200 text-gray-700 rounded text-sm hover:bg-gray-300">Экспорт CSV</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- hidden inputs kept for JS compatibility -->
|
||
<input type="hidden" id="custom-price-input" value="">
|
||
<div id="adjusted-prices" class="hidden"></div>
|
||
<div id="discount-info" class="hidden"></div>
|
||
<div id="sale-prices" class="hidden"></div>
|
||
|
||
</div><!-- end top-section-estimate -->
|
||
|
||
<!-- Top-tab section: BOM вендора -->
|
||
<div id="top-section-bom" class="hidden">
|
||
<div class="bg-white rounded-lg shadow p-4">
|
||
<div class="mb-3">
|
||
<p class="text-sm font-medium text-gray-700 mb-2">Вставьте таблицу из Excel (Ctrl+V в область ниже)</p>
|
||
<div class="bg-gray-50 border border-gray-200 rounded p-3 text-xs text-gray-500 space-y-1">
|
||
<p class="font-medium text-gray-600">Колонки определяются автоматически по содержимому. Обязательны: PN и Кол-во. Лишние колонки (секция, код и т.п.) игнорируются.</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">Строка-заголовок пропускается автоматически.</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 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-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>
|
||
</thead>
|
||
<tbody id="bom-table-body"></tbody>
|
||
</table>
|
||
</div>
|
||
<div class="mt-3 flex items-center justify-between text-sm text-gray-600">
|
||
<div id="bom-stats"></div>
|
||
<div class="flex gap-2">
|
||
<button onclick="clearBOM()" class="px-3 py-1 bg-gray-100 text-gray-600 rounded hover:bg-gray-200 border border-gray-300">
|
||
Очистить
|
||
</button>
|
||
<button onclick="saveBOM()" class="px-3 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">
|
||
Сохранить BOM
|
||
</button>
|
||
<button onclick="applyBOMToEstimate()" class="px-3 py-1 bg-orange-600 text-white rounded hover:bg-orange-700">
|
||
Пересчитать эстимейт
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div><!-- end top-section-bom -->
|
||
|
||
<!-- Top-tab section: Ценообразование -->
|
||
<div id="top-section-pricing" class="hidden">
|
||
<div class="bg-white rounded-lg shadow p-4">
|
||
<div id="pricing-table-container">
|
||
<div class="overflow-x-auto">
|
||
<table class="w-full text-sm border-collapse">
|
||
<thead class="bg-gray-50 text-gray-700">
|
||
<tr>
|
||
<th class="px-3 py-2 text-left border-b">LOT</th>
|
||
<th class="px-3 py-2 text-left border-b">PN вендора</th>
|
||
<th class="px-3 py-2 text-left border-b">Описание</th>
|
||
<th class="px-3 py-2 text-right border-b">Кол-во</th>
|
||
<th class="px-3 py-2 text-right border-b">Estimate</th>
|
||
<th class="px-3 py-2 text-right border-b">Цена вендора</th>
|
||
<th class="px-3 py-2 text-right border-b">Склад</th>
|
||
<th class="px-3 py-2 text-right border-b">Конк.</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="pricing-table-body">
|
||
<tr><td colspan="8" class="px-3 py-8 text-center text-gray-400">Загрузите BOM вендора во вкладке «BOM вендора»</td></tr>
|
||
</tbody>
|
||
<tfoot id="pricing-table-foot" class="hidden bg-gray-50 font-semibold">
|
||
<tr>
|
||
<td colspan="4" class="px-3 py-2 text-right">Итого:</td>
|
||
<td class="px-3 py-2 text-right" id="pricing-total-estimate">—</td>
|
||
<td class="px-3 py-2 text-right font-bold" id="pricing-total-vendor">—</td>
|
||
<td class="px-3 py-2 text-right" id="pricing-total-warehouse">—</td>
|
||
<td class="px-3 py-2 text-right">—</td>
|
||
</tr>
|
||
</tfoot>
|
||
</table>
|
||
</div>
|
||
<div class="mt-4 flex items-center gap-4">
|
||
<label class="text-sm font-medium text-gray-700">Своя цена:</label>
|
||
<input type="number" id="pricing-custom-price" step="0.01" min="0" placeholder="0.00"
|
||
class="w-40 px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500"
|
||
oninput="onPricingCustomPriceInput()">
|
||
<button onclick="setPricingCustomPriceFromVendor()" class="px-3 py-2 bg-gray-100 text-gray-700 rounded hover:bg-gray-200 border border-gray-300 text-sm">
|
||
Проставить цены BOM
|
||
</button>
|
||
<button onclick="exportPricingCSV()" class="px-3 py-2 bg-green-600 text-white rounded hover:bg-green-700 text-sm">
|
||
Экспорт CSV
|
||
</button>
|
||
<span id="pricing-discount-info" class="text-sm text-gray-500 hidden">
|
||
Скидка от Estimate: <span id="pricing-discount-pct" class="font-semibold text-green-600"></span>
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div><!-- end top-section-pricing -->
|
||
|
||
</div>
|
||
|
||
<!-- Price settings modal -->
|
||
<div id="price-settings-modal" class="hidden fixed inset-0 z-50">
|
||
<div class="absolute inset-0 bg-black/40" onclick="closePriceSettingsModal()"></div>
|
||
<div class="relative max-w-xl mx-auto mt-24 bg-white rounded-lg shadow-xl border">
|
||
<div class="px-5 py-4 border-b flex items-center justify-between">
|
||
<h3 class="text-lg font-semibold text-gray-900">Настройки цен</h3>
|
||
<button type="button" onclick="closePriceSettingsModal()" class="text-gray-500 hover:text-gray-700">
|
||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
<div class="px-5 py-4 space-y-4">
|
||
<div>
|
||
<label class="block text-sm font-medium text-gray-700 mb-1">Estimate</label>
|
||
<select id="settings-pricelist-estimate" class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500"></select>
|
||
</div>
|
||
<div>
|
||
<label class="block text-sm font-medium text-gray-700 mb-1">Склад</label>
|
||
<select id="settings-pricelist-warehouse" class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500"></select>
|
||
</div>
|
||
<div>
|
||
<label class="block text-sm font-medium text-gray-700 mb-1">Конкуренты</label>
|
||
<select id="settings-pricelist-competitor" class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500"></select>
|
||
</div>
|
||
<label class="flex items-center gap-2 text-sm text-gray-700">
|
||
<input id="settings-disable-price-refresh" type="checkbox" class="rounded border-gray-300 text-blue-600 focus:ring-blue-500">
|
||
<span>Не обновлять цены</span>
|
||
</label>
|
||
<label class="flex items-center gap-2 text-sm text-gray-700">
|
||
<input id="settings-only-in-stock" type="checkbox" class="rounded border-gray-300 text-blue-600 focus:ring-blue-500">
|
||
<span>Только наличие</span>
|
||
</label>
|
||
</div>
|
||
<div class="px-5 py-4 border-t flex justify-end gap-2">
|
||
<button type="button" onclick="closePriceSettingsModal()" class="px-4 py-2 bg-gray-100 text-gray-700 rounded hover:bg-gray-200">Отмена</button>
|
||
<button type="button" onclick="applyPriceSettings()" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">Применить</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Autocomplete dropdown (shared) -->
|
||
<div id="autocomplete-dropdown" class="hidden absolute z-50 bg-white border rounded-lg shadow-lg max-h-96 overflow-y-auto w-96"></div>
|
||
|
||
<style>
|
||
.autocomplete-item {
|
||
padding: 8px 12px;
|
||
cursor: pointer;
|
||
border-bottom: 1px solid #f0f0f0;
|
||
}
|
||
.autocomplete-item:hover, .autocomplete-item.selected {
|
||
background-color: #f3f4f6;
|
||
}
|
||
.autocomplete-item:last-child {
|
||
border-bottom: none;
|
||
}
|
||
</style>
|
||
|
||
<script>
|
||
// Tab configuration - will be populated dynamically
|
||
let TAB_CONFIG = {
|
||
base: {
|
||
categories: ['MB', 'CPU', 'MEM'],
|
||
singleSelect: true,
|
||
label: 'Base'
|
||
},
|
||
storage: {
|
||
categories: ['RAID', 'M2', 'SSD', 'HDD', 'EDSFF', 'HHHL'],
|
||
singleSelect: false,
|
||
label: 'Storage',
|
||
sections: [
|
||
{ title: 'RAID Контроллеры', categories: ['RAID'] },
|
||
{ title: 'Диски', categories: ['M2', 'SSD', 'HDD', 'EDSFF', 'HHHL'] }
|
||
]
|
||
},
|
||
pci: {
|
||
categories: ['GPU', 'DPU', 'NIC', 'HCA', 'HBA'],
|
||
singleSelect: false,
|
||
label: 'PCI',
|
||
sections: [
|
||
{ title: 'GPU / DPU', categories: ['GPU', 'DPU'] },
|
||
{ title: 'NIC / HCA', categories: ['NIC', 'HCA'] },
|
||
{ title: 'HBA', categories: ['HBA'] }
|
||
]
|
||
},
|
||
power: {
|
||
categories: ['PS', 'PSU'],
|
||
singleSelect: false,
|
||
label: 'Power'
|
||
},
|
||
accessories: {
|
||
categories: ['ACC', 'CARD'],
|
||
singleSelect: false,
|
||
label: 'Accessories'
|
||
},
|
||
other: {
|
||
categories: [],
|
||
singleSelect: false,
|
||
label: 'Other'
|
||
}
|
||
};
|
||
|
||
let ASSIGNED_CATEGORIES = Object.values(TAB_CONFIG)
|
||
.flatMap(t => t.categories)
|
||
.map(c => c.toUpperCase());
|
||
|
||
// State
|
||
let configUUID = '{{.ConfigUUID}}';
|
||
let configName = '';
|
||
let currentVersionNo = 1;
|
||
let projectUUID = '';
|
||
let projectName = '';
|
||
let projectCode = '';
|
||
let projectVariant = '';
|
||
let projectIndexLoaded = false;
|
||
let projectByUUID = {};
|
||
let projectMainByCode = {};
|
||
|
||
async function loadProjectIndex() {
|
||
if (projectIndexLoaded) return;
|
||
try {
|
||
const resp = await fetch('/api/projects/all');
|
||
if (!resp.ok) return;
|
||
const data = await resp.json();
|
||
const allProjects = Array.isArray(data) ? data : (data.projects || []);
|
||
projectByUUID = {};
|
||
projectMainByCode = {};
|
||
allProjects.forEach(p => {
|
||
projectByUUID[p.uuid] = p;
|
||
const code = (p.code || '').trim();
|
||
const variant = (p.variant || '').trim();
|
||
if (code && (variant === '' || variant === 'main')) {
|
||
if (!projectMainByCode[code]) {
|
||
projectMainByCode[code] = p.uuid;
|
||
}
|
||
}
|
||
});
|
||
projectIndexLoaded = true;
|
||
} catch (e) {
|
||
// ignore
|
||
}
|
||
}
|
||
|
||
function updateConfigBreadcrumbs() {
|
||
const codeEl = document.getElementById('breadcrumb-project-code');
|
||
const variantEl = document.getElementById('breadcrumb-project-variant');
|
||
const configEl = document.getElementById('breadcrumb-config-name');
|
||
const versionEl = document.getElementById('breadcrumb-config-version');
|
||
const projectCodeLinkEl = document.getElementById('breadcrumb-project-code-link');
|
||
const projectVariantLinkEl = document.getElementById('breadcrumb-project-variant-link');
|
||
|
||
let code = 'Без проекта';
|
||
let variant = 'main';
|
||
if (projectUUID && projectByUUID[projectUUID]) {
|
||
code = (projectByUUID[projectUUID].code || '').trim() || 'Без проекта';
|
||
const rawVariant = (projectByUUID[projectUUID].variant || '').trim();
|
||
variant = rawVariant === '' ? 'main' : rawVariant;
|
||
if (projectCodeLinkEl) {
|
||
const mainUUID = projectMainByCode[code];
|
||
projectCodeLinkEl.href = mainUUID ? ('/projects/' + mainUUID) : ('/projects/' + projectUUID);
|
||
}
|
||
if (projectVariantLinkEl) {
|
||
projectVariantLinkEl.href = '/projects/' + projectUUID;
|
||
}
|
||
} else {
|
||
if (projectCodeLinkEl) projectCodeLinkEl.href = '/projects';
|
||
if (projectVariantLinkEl) projectVariantLinkEl.href = '/projects';
|
||
}
|
||
codeEl.textContent = code;
|
||
variantEl.textContent = variant;
|
||
const fullConfigName = configName || 'Конфигурация';
|
||
configEl.textContent = truncateBreadcrumbSpecName(fullConfigName);
|
||
configEl.title = fullConfigName;
|
||
versionEl.textContent = 'v' + (currentVersionNo || 1);
|
||
const configNameLinkEl = document.getElementById('breadcrumb-config-name-link');
|
||
if (configNameLinkEl && configUUID) {
|
||
configNameLinkEl.href = '/configs/' + configUUID + '/revisions';
|
||
}
|
||
}
|
||
|
||
function truncateBreadcrumbSpecName(name) {
|
||
const maxLength = 16;
|
||
if (!name || name.length <= maxLength) return name;
|
||
return name.slice(0, maxLength - 1) + '…';
|
||
}
|
||
let currentTab = 'base';
|
||
let allComponents = [];
|
||
let cart = [];
|
||
let categoryOrderMap = {}; // Category code -> display_order mapping
|
||
let autoSaveTimeout = null; // Timeout for debounced autosave
|
||
let hasUnsavedChanges = false;
|
||
let exitSaveStarted = false;
|
||
let serverCount = 1; // Server count for the configuration
|
||
let serverModelForQuote = '';
|
||
let supportCode = '';
|
||
let currentArticle = '';
|
||
let articlePreviewTimeout = null;
|
||
let selectedPricelistIds = {
|
||
estimate: null,
|
||
warehouse: null,
|
||
competitor: null
|
||
};
|
||
let resolvedAutoPricelistIds = {
|
||
estimate: null,
|
||
warehouse: null,
|
||
competitor: null
|
||
};
|
||
let disablePriceRefresh = false;
|
||
let onlyInStock = false;
|
||
let activePricelistsBySource = {
|
||
estimate: [],
|
||
warehouse: [],
|
||
competitor: []
|
||
};
|
||
let activePricelistsLoadedAt = 0;
|
||
let activePricelistsLoadPromise = null;
|
||
let priceLevelsRequestSeq = 0;
|
||
let priceLevelsRefreshTimer = null;
|
||
let warehouseStockLotsByPricelist = new Map();
|
||
let warehouseStockLoadSeq = 0;
|
||
let warehouseStockLoadsByPricelist = new Map();
|
||
let componentPricesCache = {}; // { lot_name: price } - caches prices loaded via API
|
||
let componentPricesCacheLoading = new Map(); // { category: Promise } - tracks ongoing price loads
|
||
|
||
// Autocomplete state
|
||
let autocompleteInput = null;
|
||
let autocompleteCategory = null;
|
||
let autocompleteMode = null; // 'single', 'multi', 'section'
|
||
let autocompleteIndex = -1;
|
||
let autocompleteFiltered = [];
|
||
|
||
function getDisplayPrice(item) {
|
||
if (typeof item.unit_price === 'number' && item.unit_price > 0) {
|
||
return item.unit_price;
|
||
}
|
||
if (typeof item.estimate_price === 'number' && item.estimate_price > 0) {
|
||
return item.estimate_price;
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
function formatNumberRu(value) {
|
||
const rounded = Math.round(value);
|
||
return rounded
|
||
.toLocaleString('ru-RU', { minimumFractionDigits: 0, maximumFractionDigits: 0 })
|
||
.replace(/[\u202f\u00a0 ]/g, '\u00A0');
|
||
}
|
||
|
||
function formatMoney(value) {
|
||
return '$\u00A0' + formatNumberRu(value);
|
||
}
|
||
|
||
function formatPriceOrNA(value) {
|
||
if (typeof value !== 'number' || value <= 0) {
|
||
return 'N/A';
|
||
}
|
||
return formatMoney(value);
|
||
}
|
||
|
||
function formatDelta(abs, pct) {
|
||
if (typeof abs !== 'number') {
|
||
return 'N/A';
|
||
}
|
||
const sign = abs > 0 ? '+' : abs < 0 ? '-' : '';
|
||
const absValue = Math.abs(abs);
|
||
if (typeof pct !== 'number') {
|
||
return sign + formatMoney(absValue);
|
||
}
|
||
const pctSign = pct > 0 ? '+' : pct < 0 ? '-' : '';
|
||
return sign + formatMoney(absValue) + ' (' + pctSign + Math.round(Math.abs(pct)) + '%)';
|
||
}
|
||
|
||
function getEffectivePricelistID(source) {
|
||
const explicit = selectedPricelistIds[source];
|
||
if (Number.isFinite(explicit) && explicit > 0) {
|
||
return Number(explicit);
|
||
}
|
||
const resolvedAuto = resolvedAutoPricelistIds[source];
|
||
if (Number.isFinite(resolvedAuto) && resolvedAuto > 0) {
|
||
return Number(resolvedAuto);
|
||
}
|
||
const fallback = activePricelistsBySource[source]?.[0]?.id;
|
||
if (Number.isFinite(fallback) && fallback > 0) {
|
||
return Number(fallback);
|
||
}
|
||
return null;
|
||
}
|
||
|
||
async function refreshPriceLevels(options = {}) {
|
||
const force = options.force === true;
|
||
const noCache = options.noCache === true;
|
||
if (!configUUID || cart.length === 0 || (disablePriceRefresh && !force)) {
|
||
return;
|
||
}
|
||
|
||
const seq = ++priceLevelsRequestSeq;
|
||
try {
|
||
const payload = {
|
||
items: cart.map(item => ({
|
||
lot_name: item.lot_name,
|
||
quantity: item.quantity
|
||
})),
|
||
no_cache: noCache,
|
||
pricelist_ids: Object.fromEntries(
|
||
Object.entries(selectedPricelistIds)
|
||
.filter(([, id]) => typeof id === 'number' && id > 0)
|
||
)
|
||
};
|
||
const resp = await fetch('/api/quote/price-levels', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(payload)
|
||
});
|
||
if (!resp.ok) {
|
||
return;
|
||
}
|
||
const data = await resp.json();
|
||
if (seq !== priceLevelsRequestSeq) {
|
||
return;
|
||
}
|
||
const byLot = new Map((data.items || []).map(i => [i.lot_name, i]));
|
||
cart = cart.map(item => {
|
||
const levels = byLot.get(item.lot_name);
|
||
if (!levels) return item;
|
||
const next = { ...item, ...levels };
|
||
if (typeof levels.estimate_price === 'number' && levels.estimate_price > 0) {
|
||
next.unit_price = levels.estimate_price;
|
||
}
|
||
return next;
|
||
});
|
||
if (data.resolved_pricelist_ids) {
|
||
['estimate', 'warehouse', 'competitor'].forEach(source => {
|
||
if (!selectedPricelistIds[source] && data.resolved_pricelist_ids[source]) {
|
||
resolvedAutoPricelistIds[source] = Number(data.resolved_pricelist_ids[source]);
|
||
}
|
||
});
|
||
renderPricelistSettingsSummary();
|
||
}
|
||
} catch(e) {
|
||
console.error('Failed to refresh price levels', e);
|
||
}
|
||
}
|
||
|
||
function schedulePriceLevelsRefresh(options = {}) {
|
||
const delay = Number.isFinite(options.delay) ? options.delay : 120;
|
||
const rerender = options.rerender !== false;
|
||
const autosave = options.autosave === true;
|
||
const noCache = options.noCache === true;
|
||
const force = options.force === true;
|
||
|
||
if (priceLevelsRefreshTimer) {
|
||
clearTimeout(priceLevelsRefreshTimer);
|
||
priceLevelsRefreshTimer = null;
|
||
}
|
||
|
||
priceLevelsRefreshTimer = setTimeout(async () => {
|
||
priceLevelsRefreshTimer = null;
|
||
await refreshPriceLevels({ noCache, force });
|
||
if (rerender) {
|
||
renderTab();
|
||
updateCartUI();
|
||
}
|
||
if (autosave) {
|
||
triggerAutoSave();
|
||
}
|
||
}, Math.max(0, delay));
|
||
}
|
||
|
||
function currentWarehousePricelistID() {
|
||
return getEffectivePricelistID('warehouse');
|
||
}
|
||
|
||
async function loadWarehouseInStockLots() {
|
||
const pricelistID = currentWarehousePricelistID();
|
||
if (!pricelistID) return new Set();
|
||
if (warehouseStockLotsByPricelist.has(pricelistID)) {
|
||
return warehouseStockLotsByPricelist.get(pricelistID);
|
||
}
|
||
const existingLoad = warehouseStockLoadsByPricelist.get(pricelistID);
|
||
if (existingLoad) {
|
||
return existingLoad;
|
||
}
|
||
|
||
const loadPromise = (async () => {
|
||
const seq = ++warehouseStockLoadSeq;
|
||
const result = new Set();
|
||
const resp = await fetch(`/api/pricelists/${pricelistID}/lots`);
|
||
if (!resp.ok) {
|
||
throw new Error(`warehouse lots request failed: ${resp.status}`);
|
||
}
|
||
const data = await resp.json();
|
||
const lotNames = Array.isArray(data.lot_names) ? data.lot_names : [];
|
||
lotNames.forEach(lot => {
|
||
if (typeof lot === 'string' && lot.trim() !== '') {
|
||
result.add(lot);
|
||
}
|
||
});
|
||
|
||
if (seq === warehouseStockLoadSeq) {
|
||
warehouseStockLotsByPricelist.set(pricelistID, result);
|
||
}
|
||
return result;
|
||
})();
|
||
|
||
warehouseStockLoadsByPricelist.set(pricelistID, loadPromise);
|
||
try {
|
||
return await loadPromise;
|
||
} finally {
|
||
warehouseStockLoadsByPricelist.delete(pricelistID);
|
||
}
|
||
}
|
||
|
||
async function ensureWarehouseStockFilterLoaded() {
|
||
if (!onlyInStock) return;
|
||
try {
|
||
await loadWarehouseInStockLots();
|
||
} catch (e) {
|
||
console.error('Failed to load warehouse availability filter', e);
|
||
showToast('Не удалось загрузить наличие склада', 'error');
|
||
}
|
||
}
|
||
|
||
function isComponentAllowedByStockFilter(comp) {
|
||
if (!onlyInStock) return true;
|
||
const pricelistID = currentWarehousePricelistID();
|
||
if (!pricelistID) return false;
|
||
const availableLots = warehouseStockLotsByPricelist.get(pricelistID);
|
||
// Don't block UI while stock set is being loaded.
|
||
if (!availableLots) return true;
|
||
return availableLots.has(comp.lot_name);
|
||
}
|
||
|
||
// Load categories from API and update tab configuration
|
||
async function loadCategoriesFromAPI() {
|
||
try {
|
||
const resp = await fetch('/api/categories');
|
||
const cats = await resp.json();
|
||
|
||
// Build category order map
|
||
categoryOrderMap = {};
|
||
cats.forEach(cat => {
|
||
categoryOrderMap[cat.code.toUpperCase()] = cat.display_order;
|
||
});
|
||
|
||
// Build list of unassigned categories
|
||
const knownCodes = Object.values(TAB_CONFIG)
|
||
.flatMap(t => t.categories)
|
||
.map(c => c.toUpperCase());
|
||
|
||
const unassignedCategories = cats
|
||
.filter(cat => !knownCodes.includes(cat.code.toUpperCase()))
|
||
.sort((a, b) => a.display_order - b.display_order)
|
||
.map(cat => cat.code);
|
||
|
||
// Update "other" tab with unassigned categories
|
||
TAB_CONFIG.other.categories = unassignedCategories;
|
||
|
||
// Rebuild ASSIGNED_CATEGORIES
|
||
ASSIGNED_CATEGORIES = Object.values(TAB_CONFIG)
|
||
.flatMap(t => t.categories)
|
||
.map(c => c.toUpperCase());
|
||
} catch(e) {
|
||
console.error('Failed to load categories, using defaults', e);
|
||
// Will use default configuration if API fails
|
||
}
|
||
}
|
||
|
||
// Initialize
|
||
document.addEventListener('DOMContentLoaded', async function() {
|
||
// RBAC disabled - no token check required
|
||
if (!configUUID) {
|
||
window.location.href = '/configs';
|
||
return;
|
||
}
|
||
|
||
// Load categories in background (defaults are usable immediately).
|
||
const categoriesPromise = loadCategoriesFromAPI().catch(() => {});
|
||
|
||
try {
|
||
const resp = await fetch('/api/configs/' + configUUID);
|
||
|
||
if (resp.status === 404) {
|
||
showToast('Конфигурация не найдена', 'error');
|
||
window.location.href = '/configs';
|
||
return;
|
||
}
|
||
|
||
const config = await resp.json();
|
||
configName = config.name;
|
||
currentVersionNo = config.current_version_no || 1;
|
||
projectUUID = config.project_uuid || '';
|
||
await loadProjectIndex();
|
||
updateConfigBreadcrumbs();
|
||
document.getElementById('save-buttons').classList.remove('hidden');
|
||
|
||
// Set server count from config
|
||
serverCount = config.server_count || 1;
|
||
document.getElementById('server-count').value = serverCount;
|
||
document.getElementById('total-server-count').textContent = serverCount;
|
||
selectedPricelistIds.estimate = config.pricelist_id || null;
|
||
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();
|
||
}
|
||
function _bomLotValid(v) {
|
||
return (window._bomAllComponents || allComponents).some(c => c.lot_name === v);
|
||
}
|
||
|
||
function updateServerCount() {
|
||
const serverCountInput = document.getElementById('server-count');
|
||
const newCount = parseInt(serverCountInput.value) || 1;
|
||
serverCount = Math.max(1, newCount);
|
||
serverCountInput.value = serverCount;
|
||
|
||
// Update total server count display
|
||
document.getElementById('total-server-count').textContent = serverCount;
|
||
|
||
// Update cart UI to reflect the server count
|
||
updateCartUI();
|
||
|
||
// Trigger auto-save
|
||
triggerAutoSave();
|
||
}
|
||
|
||
async function loadActivePricelists(force = false) {
|
||
const now = Date.now();
|
||
const isFresh = (now - activePricelistsLoadedAt) < 15000;
|
||
if (!force && isFresh) {
|
||
return;
|
||
}
|
||
if (activePricelistsLoadPromise) {
|
||
await activePricelistsLoadPromise;
|
||
return;
|
||
}
|
||
|
||
const sources = ['estimate', 'warehouse', 'competitor'];
|
||
activePricelistsLoadPromise = (async () => {
|
||
await Promise.all(sources.map(async source => {
|
||
try {
|
||
const resp = await fetch(`/api/pricelists?active_only=true&source=${source}&per_page=200`);
|
||
const data = await resp.json();
|
||
activePricelistsBySource[source] = data.pricelists || [];
|
||
const existing = selectedPricelistIds[source];
|
||
if (existing && activePricelistsBySource[source].some(pl => Number(pl.id) === Number(existing))) {
|
||
return;
|
||
}
|
||
selectedPricelistIds[source] = null;
|
||
} catch (e) {
|
||
activePricelistsBySource[source] = [];
|
||
selectedPricelistIds[source] = null;
|
||
}
|
||
}));
|
||
activePricelistsLoadedAt = Date.now();
|
||
})();
|
||
|
||
try {
|
||
await activePricelistsLoadPromise;
|
||
} finally {
|
||
activePricelistsLoadPromise = null;
|
||
}
|
||
}
|
||
|
||
function renderPricelistSelectOptions(selectId, source) {
|
||
const select = document.getElementById(selectId);
|
||
if (!select) return;
|
||
const pricelists = activePricelistsBySource[source] || [];
|
||
if (pricelists.length === 0) {
|
||
select.innerHTML = '<option value="">Нет активных прайслистов</option>';
|
||
select.value = '';
|
||
return;
|
||
}
|
||
select.innerHTML = `<option value="">Авто (последний активный)</option>` + pricelists.map(pl => {
|
||
return `<option value="${pl.id}">${escapeHtml(pl.version)}</option>`;
|
||
}).join('');
|
||
const current = selectedPricelistIds[source];
|
||
select.value = current ? String(current) : '';
|
||
}
|
||
|
||
function syncPriceSettingsControls() {
|
||
renderPricelistSelectOptions('settings-pricelist-estimate', 'estimate');
|
||
renderPricelistSelectOptions('settings-pricelist-warehouse', 'warehouse');
|
||
renderPricelistSelectOptions('settings-pricelist-competitor', 'competitor');
|
||
const disableCheckbox = document.getElementById('settings-disable-price-refresh');
|
||
if (disableCheckbox) {
|
||
disableCheckbox.checked = disablePriceRefresh;
|
||
}
|
||
const inStockCheckbox = document.getElementById('settings-only-in-stock');
|
||
if (inStockCheckbox) {
|
||
inStockCheckbox.checked = onlyInStock;
|
||
}
|
||
}
|
||
|
||
function getPricelistVersionById(source, id) {
|
||
const pricelists = activePricelistsBySource[source] || [];
|
||
const found = pricelists.find(pl => Number(pl.id) === Number(id));
|
||
return found ? found.version : null;
|
||
}
|
||
|
||
function renderPricelistSettingsSummary() {
|
||
const summary = document.getElementById('pricelist-settings-summary');
|
||
if (!summary) return;
|
||
const estimate = selectedPricelistIds.estimate ? getPricelistVersionById('estimate', selectedPricelistIds.estimate) || `ID ${selectedPricelistIds.estimate}` : 'авто';
|
||
const warehouse = selectedPricelistIds.warehouse ? getPricelistVersionById('warehouse', selectedPricelistIds.warehouse) || `ID ${selectedPricelistIds.warehouse}` : 'авто';
|
||
const competitor = selectedPricelistIds.competitor ? getPricelistVersionById('competitor', selectedPricelistIds.competitor) || `ID ${selectedPricelistIds.competitor}` : 'авто';
|
||
const refreshState = disablePriceRefresh ? ' | Обновление цен: выкл' : '';
|
||
const stockFilterState = onlyInStock ? ' | Только наличие: вкл' : '';
|
||
summary.textContent = `Estimate: ${estimate}, Склад: ${warehouse}, Конкуренты: ${competitor}${refreshState}${stockFilterState}`;
|
||
}
|
||
|
||
function updateRefreshPricesButtonState() {
|
||
const refreshBtn = document.getElementById('refresh-prices-btn');
|
||
if (!refreshBtn) return;
|
||
if (disablePriceRefresh) {
|
||
refreshBtn.disabled = true;
|
||
refreshBtn.className = 'px-4 py-2 bg-gray-300 text-gray-500 rounded cursor-not-allowed';
|
||
refreshBtn.title = 'Обновление цен отключено в настройках';
|
||
} else {
|
||
refreshBtn.disabled = false;
|
||
refreshBtn.className = 'px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700';
|
||
refreshBtn.title = '';
|
||
}
|
||
}
|
||
|
||
function getPriceSettingsStorageKey() {
|
||
return `qf_price_settings_${configUUID || 'default'}`;
|
||
}
|
||
|
||
function persistLocalPriceSettings() {
|
||
try {
|
||
localStorage.setItem(getPriceSettingsStorageKey(), JSON.stringify({
|
||
pricelist_ids: selectedPricelistIds,
|
||
disable_price_refresh: disablePriceRefresh
|
||
}));
|
||
} catch (e) {
|
||
// ignore localStorage failures
|
||
}
|
||
}
|
||
|
||
function restoreLocalPriceSettings() {
|
||
try {
|
||
const raw = localStorage.getItem(getPriceSettingsStorageKey());
|
||
if (!raw) return;
|
||
const parsed = JSON.parse(raw);
|
||
if (parsed && parsed.pricelist_ids) {
|
||
['estimate', 'warehouse', 'competitor'].forEach(source => {
|
||
const next = parseInt(parsed.pricelist_ids[source]);
|
||
if (Number.isFinite(next) && next > 0) {
|
||
selectedPricelistIds[source] = next;
|
||
}
|
||
});
|
||
}
|
||
disablePriceRefresh = Boolean(parsed?.disable_price_refresh);
|
||
} catch (e) {
|
||
// ignore invalid localStorage payload
|
||
}
|
||
}
|
||
|
||
function openPriceSettingsModal() {
|
||
syncPriceSettingsControls();
|
||
renderPricelistSettingsSummary();
|
||
document.getElementById('price-settings-modal')?.classList.remove('hidden');
|
||
loadActivePricelists().then(() => {
|
||
syncPriceSettingsControls();
|
||
renderPricelistSettingsSummary();
|
||
});
|
||
}
|
||
|
||
function closePriceSettingsModal() {
|
||
document.getElementById('price-settings-modal')?.classList.add('hidden');
|
||
}
|
||
|
||
function applyPriceSettings() {
|
||
const estimateVal = parseInt(document.getElementById('settings-pricelist-estimate')?.value || '');
|
||
const warehouseVal = parseInt(document.getElementById('settings-pricelist-warehouse')?.value || '');
|
||
const competitorVal = parseInt(document.getElementById('settings-pricelist-competitor')?.value || '');
|
||
const disableVal = Boolean(document.getElementById('settings-disable-price-refresh')?.checked);
|
||
const inStockVal = Boolean(document.getElementById('settings-only-in-stock')?.checked);
|
||
|
||
const prevWarehouseID = currentWarehousePricelistID();
|
||
selectedPricelistIds.estimate = Number.isFinite(estimateVal) && estimateVal > 0 ? estimateVal : null;
|
||
selectedPricelistIds.warehouse = Number.isFinite(warehouseVal) && warehouseVal > 0 ? warehouseVal : null;
|
||
selectedPricelistIds.competitor = Number.isFinite(competitorVal) && competitorVal > 0 ? competitorVal : null;
|
||
if (selectedPricelistIds.estimate) {
|
||
resolvedAutoPricelistIds.estimate = null;
|
||
}
|
||
if (selectedPricelistIds.warehouse) {
|
||
resolvedAutoPricelistIds.warehouse = null;
|
||
}
|
||
if (selectedPricelistIds.competitor) {
|
||
resolvedAutoPricelistIds.competitor = null;
|
||
}
|
||
disablePriceRefresh = disableVal;
|
||
onlyInStock = inStockVal;
|
||
|
||
const nextWarehouseID = currentWarehousePricelistID();
|
||
if (Number.isFinite(prevWarehouseID) && prevWarehouseID > 0 && prevWarehouseID !== nextWarehouseID) {
|
||
warehouseStockLotsByPricelist.delete(prevWarehouseID);
|
||
}
|
||
if (onlyInStock) {
|
||
ensureWarehouseStockFilterLoaded().then(() => {
|
||
renderTab();
|
||
updateCartUI();
|
||
});
|
||
}
|
||
|
||
updateRefreshPricesButtonState();
|
||
renderPricelistSettingsSummary();
|
||
persistLocalPriceSettings();
|
||
closePriceSettingsModal();
|
||
schedulePriceLevelsRefresh({ delay: 0, rerender: true, autosave: true });
|
||
}
|
||
|
||
function getCategoryFromLotName(lotName) {
|
||
const parts = lotName.split('_');
|
||
return parts[0] || '';
|
||
}
|
||
|
||
function getComponentCategory(comp) {
|
||
return (comp.category || getCategoryFromLotName(comp.lot_name)).toUpperCase();
|
||
}
|
||
|
||
function getTabForCategory(category) {
|
||
const cat = category.toUpperCase();
|
||
for (const [tabKey, tabConfig] of Object.entries(TAB_CONFIG)) {
|
||
if (tabConfig.categories.map(c => c.toUpperCase()).includes(cat)) {
|
||
return tabKey;
|
||
}
|
||
}
|
||
return 'other';
|
||
}
|
||
|
||
function switchTab(tab) {
|
||
currentTab = tab;
|
||
document.querySelectorAll('.tab-btn').forEach(btn => {
|
||
if (btn.dataset.tab === tab) {
|
||
btn.classList.remove('border-transparent', 'text-gray-500');
|
||
btn.classList.add('border-blue-600', 'text-blue-600');
|
||
} else {
|
||
btn.classList.add('border-transparent', 'text-gray-500');
|
||
btn.classList.remove('border-blue-600', 'text-blue-600');
|
||
}
|
||
});
|
||
hideAutocomplete();
|
||
renderTab();
|
||
}
|
||
|
||
function getComponentsForTab(tab) {
|
||
const config = TAB_CONFIG[tab];
|
||
return allComponents.filter(comp => {
|
||
const category = getComponentCategory(comp);
|
||
if (tab === 'other') {
|
||
return !ASSIGNED_CATEGORIES.includes(category);
|
||
}
|
||
return config.categories.map(c => c.toUpperCase()).includes(category);
|
||
});
|
||
}
|
||
|
||
function getComponentsForCategory(category) {
|
||
return allComponents.filter(comp => {
|
||
return getComponentCategory(comp) === category.toUpperCase();
|
||
});
|
||
}
|
||
|
||
function renderTab() {
|
||
const config = TAB_CONFIG[currentTab];
|
||
const components = getComponentsForTab(currentTab);
|
||
|
||
if (config.singleSelect) {
|
||
renderSingleSelectTab(config.categories);
|
||
} else if (config.sections) {
|
||
renderMultiSelectTabWithSections(config.sections);
|
||
} else {
|
||
renderMultiSelectTab(components);
|
||
}
|
||
}
|
||
|
||
function renderSingleSelectTab(categories) {
|
||
let html = '';
|
||
if (currentTab === 'base') {
|
||
html += `
|
||
<div class="mb-1 grid grid-cols-1 md:grid-cols-[1fr,16rem] gap-3 items-start">
|
||
<label for="server-model-input" class="block text-sm font-medium text-gray-700">Модель сервера для КП:</label>
|
||
<label for="support-code-select" class="block text-sm font-medium text-gray-700">Уровень техподдержки:</label>
|
||
</div>
|
||
<div class="mb-3 grid grid-cols-1 md:grid-cols-[1fr,16rem] gap-3 items-start">
|
||
<input type="text"
|
||
id="server-model-input"
|
||
value="${escapeHtml(serverModelForQuote)}"
|
||
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500"
|
||
oninput="updateServerModelForQuote(this.value)">
|
||
<select id="support-code-select"
|
||
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500"
|
||
onchange="updateSupportCode(this.value)">
|
||
<option value="">—</option>
|
||
<option value="1yW" ${supportCode === '1yW' ? 'selected' : ''}>1yW</option>
|
||
<option value="1yB" ${supportCode === '1yB' ? 'selected' : ''}>1yB</option>
|
||
<option value="1yS" ${supportCode === '1yS' ? 'selected' : ''}>1yS</option>
|
||
<option value="1yP" ${supportCode === '1yP' ? 'selected' : ''}>1yP</option>
|
||
</select>
|
||
</div>
|
||
`;
|
||
}
|
||
html += `
|
||
<table class="w-full">
|
||
<thead class="bg-gray-50">
|
||
<tr>
|
||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase w-24">Тип</th>
|
||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">LOT</th>
|
||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Описание</th>
|
||
<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase w-24">Estimate</th>
|
||
<th class="px-3 py-2 text-center text-xs font-medium text-gray-500 uppercase w-20">Кол-во</th>
|
||
<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase w-28">Стоимость</th>
|
||
<th class="px-3 py-2 w-10"></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody class="divide-y">
|
||
`;
|
||
|
||
categories.forEach(cat => {
|
||
const catLabel = cat === 'MB' ? 'MB' : cat === 'CPU' ? 'CPU' : cat === 'MEM' ? 'MEM' : cat;
|
||
const selectedItem = cart.find(item =>
|
||
(item.category || getCategoryFromLotName(item.lot_name)).toUpperCase() === cat.toUpperCase()
|
||
);
|
||
|
||
const comp = selectedItem ? allComponents.find(c => c.lot_name === selectedItem.lot_name) : null;
|
||
const price = comp?.current_price || 0;
|
||
const estimate = selectedItem?.estimate_price ?? price;
|
||
const qty = selectedItem?.quantity || 1;
|
||
const total = (selectedItem ? getDisplayPrice(selectedItem) : price) * qty;
|
||
|
||
html += `
|
||
<tr class="hover:bg-gray-50">
|
||
<td class="px-3 py-2 text-sm font-medium text-gray-700">${catLabel}</td>
|
||
<td class="px-3 py-2">
|
||
<div class="autocomplete-wrapper relative">
|
||
<input type="text"
|
||
id="input-${cat}"
|
||
value="${selectedItem?.lot_name || ''}"
|
||
placeholder="Введите артикул..."
|
||
class="w-full px-2 py-1 border rounded text-sm font-mono"
|
||
onfocus="showAutocomplete('${cat}', this)"
|
||
oninput="filterAutocomplete('${cat}', this.value)"
|
||
onkeydown="handleAutocompleteKey(event, '${cat}')">
|
||
</div>
|
||
</td>
|
||
<td class="px-3 py-2 text-sm text-gray-500 truncate max-w-xs" id="desc-${cat}">${escapeHtml(comp?.description || '')}</td>
|
||
<td class="px-3 py-2 text-sm text-right" id="price-${cat}">${formatPriceOrNA(estimate)}</td>
|
||
<td class="px-3 py-2 text-center">
|
||
<input type="number" min="1" value="${qty}"
|
||
id="qty-${cat}"
|
||
onchange="updateSingleQuantity('${cat}', this.value)"
|
||
class="w-16 px-2 py-1 border rounded text-center text-sm">
|
||
</td>
|
||
<td class="px-3 py-2 text-sm text-right font-medium" id="total-${cat}">${total ? formatMoney(total) : '—'}</td>
|
||
<td class="px-3 py-2 text-center">
|
||
${selectedItem ? `
|
||
<button onclick="clearSingleSelect('${cat}')" class="text-red-500 hover:text-red-700">
|
||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||
</svg>
|
||
</button>
|
||
` : ''}
|
||
</td>
|
||
</tr>
|
||
`;
|
||
});
|
||
|
||
html += '</tbody></table>';
|
||
document.getElementById('tab-content').innerHTML = html;
|
||
}
|
||
|
||
function renderMultiSelectTab(components) {
|
||
// Get cart items for this tab
|
||
const tabItems = cart.filter(item => {
|
||
const cat = (item.category || getCategoryFromLotName(item.lot_name)).toUpperCase();
|
||
const tab = getTabForCategory(cat);
|
||
return tab === currentTab;
|
||
});
|
||
|
||
let html = `
|
||
<table class="w-full">
|
||
<thead class="bg-gray-50">
|
||
<tr>
|
||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">LOT</th>
|
||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Описание</th>
|
||
<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase w-24">Estimate</th>
|
||
<th class="px-3 py-2 text-center text-xs font-medium text-gray-500 uppercase w-20">Кол-во</th>
|
||
<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase w-28">Стоимость</th>
|
||
<th class="px-3 py-2 w-10"></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody class="divide-y">
|
||
`;
|
||
|
||
// Render existing cart items for this tab
|
||
tabItems.forEach((item, idx) => {
|
||
const comp = allComponents.find(c => c.lot_name === item.lot_name);
|
||
const total = getDisplayPrice(item) * item.quantity;
|
||
|
||
html += `
|
||
<tr class="hover:bg-gray-50">
|
||
<td class="px-3 py-2 text-sm font-mono">${escapeHtml(item.lot_name)}</td>
|
||
<td class="px-3 py-2 text-sm text-gray-500 truncate max-w-xs">${escapeHtml(item.description || comp?.description || '')}</td>
|
||
<td class="px-3 py-2 text-sm text-right">${formatPriceOrNA(item.estimate_price ?? item.unit_price)}</td>
|
||
<td class="px-3 py-2 text-center">
|
||
<input type="number" min="1" value="${item.quantity}"
|
||
onchange="updateMultiQuantity('${item.lot_name}', this.value)"
|
||
class="w-16 px-2 py-1 border rounded text-center text-sm">
|
||
</td>
|
||
<td class="px-3 py-2 text-sm text-right font-medium">${formatMoney(total)}</td>
|
||
<td class="px-3 py-2 text-center">
|
||
<button onclick="removeFromCart('${item.lot_name}')" class="text-red-500 hover:text-red-700">
|
||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||
</svg>
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
`;
|
||
});
|
||
|
||
// Add empty row for new item
|
||
html += `
|
||
<tr class="hover:bg-gray-50 bg-gray-50">
|
||
<td class="px-3 py-2" colspan="2">
|
||
<div class="autocomplete-wrapper relative">
|
||
<input type="text"
|
||
id="input-new-item"
|
||
placeholder="Добавить компонент..."
|
||
class="w-full px-2 py-1 border rounded text-sm"
|
||
onfocus="showAutocompleteMulti(this)"
|
||
oninput="filterAutocompleteMulti(this.value)"
|
||
onkeydown="handleAutocompleteKeyMulti(event)">
|
||
</div>
|
||
</td>
|
||
<td class="px-3 py-2 text-sm text-right text-gray-400" id="new-price">N/A</td>
|
||
<td class="px-3 py-2 text-center">
|
||
<input type="number" min="1" value="1" id="new-qty"
|
||
class="w-16 px-2 py-1 border rounded text-center text-sm">
|
||
</td>
|
||
<td class="px-3 py-2 text-sm text-right text-gray-400" id="new-total">—</td>
|
||
<td class="px-3 py-2"></td>
|
||
</tr>
|
||
`;
|
||
|
||
html += '</tbody></table>';
|
||
html += `<p class="text-center text-sm text-gray-500 mt-4">Доступно компонентов: ${components.length}</p>`;
|
||
|
||
document.getElementById('tab-content').innerHTML = html;
|
||
}
|
||
|
||
function renderMultiSelectTabWithSections(sections) {
|
||
// Get cart items for this tab
|
||
const tabItems = cart.filter(item => {
|
||
const cat = (item.category || getCategoryFromLotName(item.lot_name)).toUpperCase();
|
||
const tab = getTabForCategory(cat);
|
||
return tab === currentTab;
|
||
});
|
||
|
||
let html = '';
|
||
let totalComponents = 0;
|
||
|
||
sections.forEach((section, sectionIdx) => {
|
||
// Get components for this section's categories
|
||
const sectionCategories = section.categories.map(c => c.toUpperCase());
|
||
const sectionComponents = allComponents.filter(comp => {
|
||
const category = getComponentCategory(comp);
|
||
return sectionCategories.includes(category);
|
||
});
|
||
totalComponents += sectionComponents.length;
|
||
|
||
// Get cart items for this section
|
||
const sectionItems = tabItems.filter(item => {
|
||
const cat = (item.category || getCategoryFromLotName(item.lot_name)).toUpperCase();
|
||
return sectionCategories.includes(cat);
|
||
});
|
||
|
||
// Section header
|
||
html += `
|
||
<div class="mb-6 ${sectionIdx > 0 ? 'mt-6' : ''}">
|
||
<h3 class="text-sm font-semibold text-gray-700 mb-3 px-3">${section.title}</h3>
|
||
<table class="w-full">
|
||
<thead class="bg-gray-50">
|
||
<tr>
|
||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">LOT</th>
|
||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Описание</th>
|
||
<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase w-24">Estimate</th>
|
||
<th class="px-3 py-2 text-center text-xs font-medium text-gray-500 uppercase w-20">Кол-во</th>
|
||
<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase w-28">Стоимость</th>
|
||
<th class="px-3 py-2 w-10"></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody class="divide-y">
|
||
`;
|
||
|
||
// Render existing cart items for this section
|
||
sectionItems.forEach((item) => {
|
||
const comp = allComponents.find(c => c.lot_name === item.lot_name);
|
||
const total = getDisplayPrice(item) * item.quantity;
|
||
|
||
html += `
|
||
<tr class="hover:bg-gray-50">
|
||
<td class="px-3 py-2 text-sm font-mono">${escapeHtml(item.lot_name)}</td>
|
||
<td class="px-3 py-2 text-sm text-gray-500 truncate max-w-xs">${escapeHtml(item.description || comp?.description || '')}</td>
|
||
<td class="px-3 py-2 text-sm text-right">${formatPriceOrNA(item.estimate_price ?? item.unit_price)}</td>
|
||
<td class="px-3 py-2 text-center">
|
||
<input type="number" min="1" value="${item.quantity}"
|
||
onchange="updateMultiQuantity('${item.lot_name}', this.value)"
|
||
class="w-16 px-2 py-1 border rounded text-center text-sm">
|
||
</td>
|
||
<td class="px-3 py-2 text-sm text-right font-medium">${formatMoney(total)}</td>
|
||
<td class="px-3 py-2 text-center">
|
||
<button onclick="removeFromCart('${item.lot_name}')" class="text-red-500 hover:text-red-700">
|
||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||
</svg>
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
`;
|
||
});
|
||
|
||
// Add empty row for new item in this section
|
||
const sectionId = section.categories.join('-');
|
||
const categoriesStr = section.categories.join(',');
|
||
html += `
|
||
<tr class="hover:bg-gray-50 bg-gray-50">
|
||
<td class="px-3 py-2" colspan="2">
|
||
<div class="autocomplete-wrapper relative">
|
||
<input type="text"
|
||
id="input-section-${sectionId}"
|
||
data-categories="${categoriesStr}"
|
||
placeholder="Добавить ${section.title.toLowerCase()}..."
|
||
class="w-full px-2 py-1 border rounded text-sm"
|
||
onfocus="showAutocompleteSection('${sectionId}', this)"
|
||
oninput="filterAutocompleteSection('${sectionId}', this.value, this)"
|
||
onkeydown="handleAutocompleteKeySection(event, '${sectionId}')">
|
||
</div>
|
||
</td>
|
||
<td class="px-3 py-2 text-sm text-right text-gray-400" id="new-price-${sectionId}">N/A</td>
|
||
<td class="px-3 py-2 text-center">
|
||
<input type="number" min="1" value="1" id="new-qty-${sectionId}"
|
||
class="w-16 px-2 py-1 border rounded text-center text-sm">
|
||
</td>
|
||
<td class="px-3 py-2 text-sm text-right text-gray-400" id="new-total-${sectionId}">—</td>
|
||
<td class="px-3 py-2"></td>
|
||
</tr>
|
||
`;
|
||
|
||
html += `
|
||
</tbody>
|
||
</table>
|
||
<p class="text-center text-sm text-gray-500 mt-2">Доступно: ${sectionComponents.length}</p>
|
||
</div>
|
||
`;
|
||
});
|
||
|
||
document.getElementById('tab-content').innerHTML = html;
|
||
}
|
||
|
||
// Load prices for components in a category/tab via API
|
||
async function ensurePricesLoaded(components) {
|
||
if (!components || components.length === 0) return;
|
||
|
||
// Filter out components that already have prices cached
|
||
const toLoad = components.filter(c => !(c.lot_name in componentPricesCache));
|
||
if (toLoad.length === 0) return;
|
||
|
||
try {
|
||
// Use quote/price-levels API to get prices for these components
|
||
const resp = await fetch('/api/quote/price-levels', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
items: toLoad.map(c => ({ lot_name: c.lot_name, quantity: 1 })),
|
||
pricelist_ids: Object.fromEntries(
|
||
Object.entries(selectedPricelistIds)
|
||
.filter(([, id]) => typeof id === 'number' && id > 0)
|
||
)
|
||
})
|
||
});
|
||
|
||
if (resp.ok) {
|
||
const data = await resp.json();
|
||
if (data.items) {
|
||
data.items.forEach(item => {
|
||
// Cache the estimate price (or 0 if not found)
|
||
componentPricesCache[item.lot_name] = item.estimate_price || 0;
|
||
});
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.error('Failed to load component prices', e);
|
||
}
|
||
}
|
||
|
||
function hasComponentPrice(lotName) {
|
||
return lotName in componentPricesCache && componentPricesCache[lotName] > 0;
|
||
}
|
||
|
||
// Autocomplete for single select (Base tab)
|
||
async function showAutocomplete(category, input) {
|
||
autocompleteInput = input;
|
||
autocompleteCategory = category;
|
||
autocompleteMode = 'single';
|
||
autocompleteIndex = -1;
|
||
const components = getComponentsForCategory(category);
|
||
await ensurePricesLoaded(components);
|
||
filterAutocomplete(category, input.value);
|
||
}
|
||
|
||
function filterAutocomplete(category, search) {
|
||
const components = getComponentsForCategory(category);
|
||
const searchLower = search.toLowerCase();
|
||
|
||
autocompleteFiltered = components.filter(c => {
|
||
if (!hasComponentPrice(c.lot_name)) return false;
|
||
if (!isComponentAllowedByStockFilter(c)) return false;
|
||
const text = (c.lot_name + ' ' + (c.description || '')).toLowerCase();
|
||
return text.includes(searchLower);
|
||
})
|
||
.sort((a, b) => {
|
||
// Sort by popularity_score desc, then by lot_name
|
||
const popDiff = (b.popularity_score || 0) - (a.popularity_score || 0);
|
||
if (popDiff !== 0) return popDiff;
|
||
return a.lot_name.localeCompare(b.lot_name);
|
||
});
|
||
|
||
renderAutocomplete();
|
||
}
|
||
|
||
function renderAutocomplete() {
|
||
const dropdown = document.getElementById('autocomplete-dropdown');
|
||
|
||
if (autocompleteFiltered.length === 0 || !autocompleteInput) {
|
||
dropdown.classList.add('hidden');
|
||
return;
|
||
}
|
||
|
||
const rect = autocompleteInput.getBoundingClientRect();
|
||
dropdown.style.top = (rect.bottom + window.scrollY) + 'px';
|
||
dropdown.style.left = rect.left + 'px';
|
||
dropdown.style.width = Math.max(rect.width, 400) + 'px';
|
||
|
||
// Build autocomplete items based on mode
|
||
dropdown.innerHTML = autocompleteFiltered.map((comp, idx) => {
|
||
let onmousedown;
|
||
|
||
if (autocompleteMode === 'section') {
|
||
onmousedown = `selectAutocompleteItemSection(${idx}, '${autocompleteCategory}')`;
|
||
} else if (autocompleteMode === 'multi') {
|
||
onmousedown = `selectAutocompleteItemMulti(${idx})`;
|
||
} else {
|
||
// single mode
|
||
onmousedown = `selectAutocompleteItem(${idx})`;
|
||
}
|
||
|
||
return `
|
||
<div class="autocomplete-item ${idx === autocompleteIndex ? 'selected' : ''}"
|
||
onmousedown="${onmousedown}">
|
||
<div class="font-mono text-sm">${escapeHtml(comp.lot_name)}</div>
|
||
<div class="text-xs text-gray-500 truncate">${escapeHtml(comp.description || '')}</div>
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
|
||
dropdown.classList.remove('hidden');
|
||
}
|
||
|
||
function handleAutocompleteKey(event, category) {
|
||
if (event.key === 'ArrowDown') {
|
||
event.preventDefault();
|
||
autocompleteIndex = Math.min(autocompleteIndex + 1, autocompleteFiltered.length - 1);
|
||
renderAutocomplete();
|
||
} else if (event.key === 'ArrowUp') {
|
||
event.preventDefault();
|
||
autocompleteIndex = Math.max(autocompleteIndex - 1, -1);
|
||
renderAutocomplete();
|
||
} else if (event.key === 'Enter') {
|
||
event.preventDefault();
|
||
if (autocompleteIndex >= 0 && autocompleteIndex < autocompleteFiltered.length) {
|
||
selectAutocompleteItem(autocompleteIndex);
|
||
}
|
||
} else if (event.key === 'Escape') {
|
||
hideAutocomplete();
|
||
}
|
||
}
|
||
|
||
function selectAutocompleteItem(index) {
|
||
const comp = autocompleteFiltered[index];
|
||
if (!comp || !autocompleteCategory) return;
|
||
|
||
// Remove existing item of this category
|
||
cart = cart.filter(item =>
|
||
(item.category || getCategoryFromLotName(item.lot_name)).toUpperCase() !== autocompleteCategory.toUpperCase()
|
||
);
|
||
|
||
const qtyInput = document.getElementById('qty-' + autocompleteCategory);
|
||
const qty = parseInt(qtyInput?.value) || 1;
|
||
const price = componentPricesCache[comp.lot_name] || 0;
|
||
|
||
cart.push({
|
||
lot_name: comp.lot_name,
|
||
quantity: qty,
|
||
unit_price: price,
|
||
estimate_price: price,
|
||
warehouse_price: null,
|
||
competitor_price: null,
|
||
delta_wh_estimate_abs: null,
|
||
delta_wh_estimate_pct: null,
|
||
delta_comp_estimate_abs: null,
|
||
delta_comp_estimate_pct: null,
|
||
delta_comp_wh_abs: null,
|
||
delta_comp_wh_pct: null,
|
||
price_missing: ['warehouse', 'competitor'],
|
||
description: comp.description || '',
|
||
category: getComponentCategory(comp)
|
||
});
|
||
|
||
hideAutocomplete();
|
||
renderTab();
|
||
updateCartUI();
|
||
triggerAutoSave();
|
||
schedulePriceLevelsRefresh({ delay: 80, rerender: true, autosave: false });
|
||
}
|
||
|
||
function hideAutocomplete() {
|
||
document.getElementById('autocomplete-dropdown').classList.add('hidden');
|
||
autocompleteInput = null;
|
||
autocompleteCategory = null;
|
||
autocompleteMode = null;
|
||
autocompleteIndex = -1;
|
||
}
|
||
|
||
// Autocomplete for multi select tabs
|
||
async function showAutocompleteMulti(input) {
|
||
autocompleteInput = input;
|
||
autocompleteCategory = null;
|
||
autocompleteMode = 'multi';
|
||
autocompleteIndex = -1;
|
||
const components = getComponentsForTab(currentTab);
|
||
await ensurePricesLoaded(components);
|
||
filterAutocompleteMulti(input.value);
|
||
}
|
||
|
||
function filterAutocompleteMulti(search) {
|
||
const components = getComponentsForTab(currentTab);
|
||
const searchLower = search.toLowerCase();
|
||
|
||
// Filter out already added items
|
||
const addedLots = new Set(cart.map(i => i.lot_name));
|
||
|
||
autocompleteFiltered = components.filter(c => {
|
||
if (!hasComponentPrice(c.lot_name)) return false;
|
||
if (addedLots.has(c.lot_name)) return false;
|
||
if (!isComponentAllowedByStockFilter(c)) return false;
|
||
const text = (c.lot_name + ' ' + (c.description || '')).toLowerCase();
|
||
return text.includes(searchLower);
|
||
})
|
||
.sort((a, b) => {
|
||
// Sort by popularity_score desc, then by lot_name
|
||
const popDiff = (b.popularity_score || 0) - (a.popularity_score || 0);
|
||
if (popDiff !== 0) return popDiff;
|
||
return a.lot_name.localeCompare(b.lot_name);
|
||
});
|
||
|
||
renderAutocomplete();
|
||
}
|
||
|
||
function handleAutocompleteKeyMulti(event) {
|
||
if (event.key === 'ArrowDown') {
|
||
event.preventDefault();
|
||
autocompleteIndex = Math.min(autocompleteIndex + 1, autocompleteFiltered.length - 1);
|
||
renderAutocomplete();
|
||
} else if (event.key === 'ArrowUp') {
|
||
event.preventDefault();
|
||
autocompleteIndex = Math.max(autocompleteIndex - 1, -1);
|
||
renderAutocomplete();
|
||
} else if (event.key === 'Enter') {
|
||
event.preventDefault();
|
||
if (autocompleteIndex >= 0 && autocompleteIndex < autocompleteFiltered.length) {
|
||
selectAutocompleteItemMulti(autocompleteIndex);
|
||
}
|
||
} else if (event.key === 'Escape') {
|
||
hideAutocomplete();
|
||
}
|
||
}
|
||
|
||
function selectAutocompleteItemMulti(index) {
|
||
const comp = autocompleteFiltered[index];
|
||
if (!comp) return;
|
||
|
||
const qtyInput = document.getElementById('new-qty');
|
||
const qty = parseInt(qtyInput?.value) || 1;
|
||
const price = componentPricesCache[comp.lot_name] || 0;
|
||
|
||
cart.push({
|
||
lot_name: comp.lot_name,
|
||
quantity: qty,
|
||
unit_price: price,
|
||
estimate_price: price,
|
||
warehouse_price: null,
|
||
competitor_price: null,
|
||
delta_wh_estimate_abs: null,
|
||
delta_wh_estimate_pct: null,
|
||
delta_comp_estimate_abs: null,
|
||
delta_comp_estimate_pct: null,
|
||
delta_comp_wh_abs: null,
|
||
delta_comp_wh_pct: null,
|
||
price_missing: ['warehouse', 'competitor'],
|
||
description: comp.description || '',
|
||
category: getComponentCategory(comp)
|
||
});
|
||
|
||
hideAutocomplete();
|
||
renderTab();
|
||
updateCartUI();
|
||
triggerAutoSave();
|
||
schedulePriceLevelsRefresh({ delay: 80, rerender: true, autosave: false });
|
||
}
|
||
|
||
// Autocomplete for sectioned tabs (like storage with RAID and Disks sections)
|
||
async function showAutocompleteSection(sectionId, input) {
|
||
autocompleteInput = input;
|
||
autocompleteCategory = sectionId; // Store section ID
|
||
autocompleteMode = 'section';
|
||
autocompleteIndex = -1;
|
||
|
||
// Load prices for tab components
|
||
const components = getComponentsForTab(currentTab);
|
||
await ensurePricesLoaded(components);
|
||
|
||
filterAutocompleteSection(sectionId, input.value, input);
|
||
}
|
||
|
||
function filterAutocompleteSection(sectionId, search, inputElement) {
|
||
const searchLower = search.toLowerCase();
|
||
|
||
// Get categories from input element's data attribute
|
||
const categoriesStr = inputElement && inputElement.dataset ? inputElement.dataset.categories : '';
|
||
if (!categoriesStr) {
|
||
autocompleteFiltered = [];
|
||
renderAutocomplete();
|
||
return;
|
||
}
|
||
|
||
const categoryList = categoriesStr.split(',').map(c => c.trim().toUpperCase());
|
||
|
||
// Get components for this section's categories
|
||
const sectionComponents = allComponents.filter(comp => {
|
||
const category = getComponentCategory(comp);
|
||
return categoryList.includes(category);
|
||
});
|
||
|
||
// Filter out already added items
|
||
const addedLots = new Set(cart.map(i => i.lot_name));
|
||
|
||
autocompleteFiltered = sectionComponents.filter(c => {
|
||
if (!hasComponentPrice(c.lot_name)) return false;
|
||
if (addedLots.has(c.lot_name)) return false;
|
||
if (!isComponentAllowedByStockFilter(c)) return false;
|
||
const text = (c.lot_name + ' ' + (c.description || '')).toLowerCase();
|
||
return text.includes(searchLower);
|
||
})
|
||
.sort((a, b) => {
|
||
// Sort by popularity_score desc, then by lot_name
|
||
const popDiff = (b.popularity_score || 0) - (a.popularity_score || 0);
|
||
if (popDiff !== 0) return popDiff;
|
||
return a.lot_name.localeCompare(b.lot_name);
|
||
});
|
||
|
||
renderAutocomplete();
|
||
}
|
||
|
||
function handleAutocompleteKeySection(event, sectionId) {
|
||
if (event.key === 'ArrowDown') {
|
||
event.preventDefault();
|
||
autocompleteIndex = Math.min(autocompleteIndex + 1, autocompleteFiltered.length - 1);
|
||
renderAutocomplete();
|
||
} else if (event.key === 'ArrowUp') {
|
||
event.preventDefault();
|
||
autocompleteIndex = Math.max(autocompleteIndex - 1, -1);
|
||
renderAutocomplete();
|
||
} else if (event.key === 'Enter') {
|
||
event.preventDefault();
|
||
if (autocompleteIndex >= 0 && autocompleteIndex < autocompleteFiltered.length) {
|
||
selectAutocompleteItemSection(autocompleteIndex, sectionId);
|
||
}
|
||
} else if (event.key === 'Escape') {
|
||
hideAutocomplete();
|
||
}
|
||
}
|
||
|
||
function selectAutocompleteItemSection(index, sectionId) {
|
||
const comp = autocompleteFiltered[index];
|
||
if (!comp) return;
|
||
|
||
const qtyInput = document.getElementById('new-qty-' + sectionId);
|
||
const qty = parseInt(qtyInput?.value) || 1;
|
||
const price = componentPricesCache[comp.lot_name] || 0;
|
||
|
||
cart.push({
|
||
lot_name: comp.lot_name,
|
||
quantity: qty,
|
||
unit_price: price,
|
||
estimate_price: price,
|
||
warehouse_price: null,
|
||
competitor_price: null,
|
||
delta_wh_estimate_abs: null,
|
||
delta_wh_estimate_pct: null,
|
||
delta_comp_estimate_abs: null,
|
||
delta_comp_estimate_pct: null,
|
||
delta_comp_wh_abs: null,
|
||
delta_comp_wh_pct: null,
|
||
price_missing: ['warehouse', 'competitor'],
|
||
description: comp.description || '',
|
||
category: getComponentCategory(comp)
|
||
});
|
||
|
||
hideAutocomplete();
|
||
|
||
// Clear the input field
|
||
const input = document.getElementById('input-section-' + sectionId);
|
||
if (input) input.value = '';
|
||
|
||
// Reset quantity to 1
|
||
if (qtyInput) qtyInput.value = '1';
|
||
renderTab();
|
||
updateCartUI();
|
||
triggerAutoSave();
|
||
schedulePriceLevelsRefresh({ delay: 80, rerender: true, autosave: false });
|
||
}
|
||
|
||
function clearSingleSelect(category) {
|
||
cart = cart.filter(item =>
|
||
(item.category || getCategoryFromLotName(item.lot_name)).toUpperCase() !== category.toUpperCase()
|
||
);
|
||
renderTab();
|
||
updateCartUI();
|
||
triggerAutoSave();
|
||
}
|
||
|
||
function updateSingleQuantity(category, value) {
|
||
const qty = parseInt(value) || 1;
|
||
const item = cart.find(i =>
|
||
(i.category || getCategoryFromLotName(i.lot_name)).toUpperCase() === category.toUpperCase()
|
||
);
|
||
|
||
if (item) {
|
||
item.quantity = Math.max(1, qty);
|
||
renderTab();
|
||
updateCartUI();
|
||
triggerAutoSave();
|
||
}
|
||
}
|
||
|
||
function updateMultiQuantity(lotName, value) {
|
||
const qty = parseInt(value) || 1;
|
||
const item = cart.find(i => i.lot_name === lotName);
|
||
|
||
if (item) {
|
||
item.quantity = Math.max(1, qty);
|
||
updateCartUI();
|
||
triggerAutoSave();
|
||
// Update total in the row
|
||
const row = document.querySelector(`input[onchange*="${lotName}"]`)?.closest('tr');
|
||
if (row) {
|
||
const totalCell = row.querySelector('td:nth-child(5)');
|
||
if (totalCell) {
|
||
totalCell.textContent = formatMoney(getDisplayPrice(item) * item.quantity);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
function removeFromCart(lotName) {
|
||
cart = cart.filter(i => i.lot_name !== lotName);
|
||
renderTab();
|
||
updateCartUI();
|
||
triggerAutoSave();
|
||
}
|
||
|
||
function updateCartUI() {
|
||
window._currentCart = cart; // expose for BOM/Pricing tabs
|
||
const total = cart.reduce((sum, item) => sum + (getDisplayPrice(item) * item.quantity), 0);
|
||
document.getElementById('cart-total').textContent = formatMoney(total);
|
||
|
||
// Recalculate custom price section if active
|
||
calculateCustomPrice();
|
||
renderSalePriceTable();
|
||
|
||
scheduleArticlePreview();
|
||
|
||
if (cart.length === 0) {
|
||
document.getElementById('cart-items').innerHTML =
|
||
'<div class="text-gray-500 text-center py-2">Конфигурация пуста</div>';
|
||
return;
|
||
}
|
||
|
||
// Sort cart items by category display order
|
||
const sortedCart = [...cart].sort((a, b) => {
|
||
const catA = (a.category || getCategoryFromLotName(a.lot_name)).toUpperCase();
|
||
const catB = (b.category || getCategoryFromLotName(b.lot_name)).toUpperCase();
|
||
const orderA = categoryOrderMap[catA] || 9999;
|
||
const orderB = categoryOrderMap[catB] || 9999;
|
||
return orderA - orderB;
|
||
});
|
||
|
||
const grouped = {};
|
||
sortedCart.forEach(item => {
|
||
const cat = item.category || getCategoryFromLotName(item.lot_name);
|
||
const tab = getTabForCategory(cat);
|
||
if (!grouped[tab]) grouped[tab] = [];
|
||
grouped[tab].push(item);
|
||
});
|
||
|
||
// Sort tabs by minimum display order of their categories
|
||
const sortedTabs = Object.entries(grouped).sort((a, b) => {
|
||
const minOrderA = Math.min(...a[1].map(item => {
|
||
const cat = (item.category || getCategoryFromLotName(item.lot_name)).toUpperCase();
|
||
return categoryOrderMap[cat] || 9999;
|
||
}));
|
||
const minOrderB = Math.min(...b[1].map(item => {
|
||
const cat = (item.category || getCategoryFromLotName(item.lot_name)).toUpperCase();
|
||
return categoryOrderMap[cat] || 9999;
|
||
}));
|
||
return minOrderA - minOrderB;
|
||
});
|
||
|
||
let html = '';
|
||
for (const [tab, items] of sortedTabs) {
|
||
const tabLabel = TAB_CONFIG[tab]?.label || tab;
|
||
html += `<div class="mb-2"><div class="text-xs font-medium text-gray-400 uppercase mb-1">${tabLabel}</div>`;
|
||
|
||
items.forEach(item => {
|
||
const itemTotal = getDisplayPrice(item) * item.quantity;
|
||
html += `
|
||
<div class="flex justify-between items-center py-1 text-sm">
|
||
<div class="flex-1">
|
||
<span class="font-mono">${escapeHtml(item.lot_name)}</span>
|
||
<span class="text-gray-400 mx-1">×</span>
|
||
<span>${item.quantity}</span>
|
||
</div>
|
||
<div class="flex items-center space-x-2">
|
||
<span>${formatMoney(itemTotal)}</span>
|
||
<button onclick="removeFromCart('${item.lot_name}')" class="text-red-500 hover:text-red-700">
|
||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
});
|
||
|
||
html += '</div>';
|
||
}
|
||
|
||
document.getElementById('cart-items').innerHTML = html;
|
||
}
|
||
|
||
function escapeHtml(text) {
|
||
if (!text) return '';
|
||
const div = document.createElement('div');
|
||
div.textContent = text;
|
||
return div.innerHTML;
|
||
}
|
||
|
||
function updateServerModelForQuote(value) {
|
||
serverModelForQuote = value || '';
|
||
scheduleArticlePreview();
|
||
}
|
||
|
||
function updateSupportCode(value) {
|
||
supportCode = value || '';
|
||
scheduleArticlePreview();
|
||
}
|
||
|
||
function scheduleArticlePreview() {
|
||
if (articlePreviewTimeout) {
|
||
clearTimeout(articlePreviewTimeout);
|
||
}
|
||
articlePreviewTimeout = setTimeout(() => {
|
||
previewArticle();
|
||
}, 250);
|
||
}
|
||
|
||
async function previewArticle() {
|
||
const el = document.getElementById('article-display');
|
||
if (!el) return;
|
||
|
||
const model = serverModelForQuote.trim();
|
||
const estimatePricelistID = getEffectivePricelistID('estimate');
|
||
if (!model || !estimatePricelistID || cart.length === 0) {
|
||
currentArticle = '';
|
||
el.textContent = 'Артикул: —';
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const resp = await fetch('/api/configs/preview-article', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({
|
||
server_model: serverModelForQuote,
|
||
support_code: supportCode,
|
||
pricelist_id: estimatePricelistID,
|
||
items: cart.map(item => ({
|
||
lot_name: item.lot_name,
|
||
quantity: item.quantity,
|
||
unit_price: item.unit_price || 0
|
||
}))
|
||
})
|
||
});
|
||
if (!resp.ok) {
|
||
currentArticle = '';
|
||
el.textContent = 'Артикул: —';
|
||
return;
|
||
}
|
||
const data = await resp.json();
|
||
currentArticle = data.article || '';
|
||
el.textContent = currentArticle ? ('Артикул: ' + currentArticle) : 'Артикул: —';
|
||
} catch(e) {
|
||
currentArticle = '';
|
||
el.textContent = 'Артикул: —';
|
||
}
|
||
}
|
||
|
||
function getCurrentArticle() {
|
||
return currentArticle || '';
|
||
}
|
||
|
||
function getAutosaveStorageKey() {
|
||
return `qf_config_autosave_${configUUID || 'default'}`;
|
||
}
|
||
|
||
function buildSavePayload() {
|
||
const customPriceInput = document.getElementById('custom-price-input');
|
||
const customPriceValue = parseFloat(customPriceInput.value);
|
||
const customPrice = customPriceValue > 0 ? customPriceValue : null;
|
||
|
||
return {
|
||
name: configName,
|
||
items: cart,
|
||
custom_price: customPrice,
|
||
notes: '',
|
||
server_count: serverCount,
|
||
server_model: serverModelForQuote,
|
||
support_code: supportCode,
|
||
article: getCurrentArticle(),
|
||
pricelist_id: selectedPricelistIds.estimate,
|
||
only_in_stock: onlyInStock
|
||
};
|
||
}
|
||
|
||
function persistAutosaveDraft() {
|
||
if (!configUUID) return;
|
||
try {
|
||
sessionStorage.setItem(getAutosaveStorageKey(), JSON.stringify({
|
||
payload: buildSavePayload(),
|
||
saved_at: Date.now()
|
||
}));
|
||
} catch (_) {
|
||
// ignore storage failures
|
||
}
|
||
}
|
||
|
||
function clearAutosaveDraft() {
|
||
try {
|
||
sessionStorage.removeItem(getAutosaveStorageKey());
|
||
} catch (_) {
|
||
// ignore storage failures
|
||
}
|
||
}
|
||
|
||
function restoreAutosaveDraftIfAny() {
|
||
if (!configUUID) return;
|
||
let raw = null;
|
||
try {
|
||
raw = sessionStorage.getItem(getAutosaveStorageKey());
|
||
} catch (_) {
|
||
raw = null;
|
||
}
|
||
if (!raw) return;
|
||
|
||
try {
|
||
const parsed = JSON.parse(raw);
|
||
const payload = parsed && parsed.payload ? parsed.payload : null;
|
||
if (!payload) return;
|
||
|
||
if (Array.isArray(payload.items)) {
|
||
cart = payload.items.map(item => ({
|
||
lot_name: item.lot_name,
|
||
quantity: item.quantity,
|
||
unit_price: item.unit_price,
|
||
estimate_price: item.unit_price,
|
||
warehouse_price: null,
|
||
competitor_price: null,
|
||
description: item.description || '',
|
||
category: item.category || getCategoryFromLotName(item.lot_name)
|
||
}));
|
||
}
|
||
if (typeof payload.server_count === 'number' && payload.server_count > 0) {
|
||
serverCount = payload.server_count;
|
||
const serverCountInput = document.getElementById('server-count');
|
||
if (serverCountInput) serverCountInput.value = serverCount;
|
||
const totalServerCount = document.getElementById('total-server-count');
|
||
if (totalServerCount) totalServerCount.textContent = serverCount;
|
||
}
|
||
serverModelForQuote = payload.server_model || serverModelForQuote;
|
||
supportCode = payload.support_code || supportCode;
|
||
currentArticle = payload.article || currentArticle;
|
||
selectedPricelistIds.estimate = payload.pricelist_id || selectedPricelistIds.estimate;
|
||
onlyInStock = Boolean(payload.only_in_stock);
|
||
|
||
const customPriceInput = document.getElementById('custom-price-input');
|
||
if (customPriceInput) {
|
||
if (typeof payload.custom_price === 'number' && payload.custom_price > 0) {
|
||
customPriceInput.value = payload.custom_price.toFixed(2);
|
||
} else {
|
||
customPriceInput.value = '';
|
||
}
|
||
}
|
||
hasUnsavedChanges = true;
|
||
} catch (_) {
|
||
// ignore invalid draft
|
||
}
|
||
}
|
||
|
||
function saveConfigOnExit() {
|
||
if (!configUUID || !hasUnsavedChanges || exitSaveStarted) return;
|
||
exitSaveStarted = true;
|
||
try {
|
||
fetch('/api/configs/' + configUUID, {
|
||
method: 'PUT',
|
||
headers: {
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify(buildSavePayload()),
|
||
keepalive: true
|
||
});
|
||
} catch (_) {
|
||
// best effort save on page exit
|
||
}
|
||
}
|
||
|
||
function triggerAutoSave() {
|
||
// Autosave keeps local draft only; server revision is created on Save/Exit.
|
||
hasUnsavedChanges = true;
|
||
if (autoSaveTimeout) {
|
||
clearTimeout(autoSaveTimeout);
|
||
}
|
||
autoSaveTimeout = setTimeout(() => {
|
||
persistAutosaveDraft();
|
||
}, 1000);
|
||
}
|
||
|
||
async function saveConfig(showNotification = true) {
|
||
// RBAC disabled - no token check required
|
||
if (!configUUID) return;
|
||
if (priceLevelsRefreshTimer) {
|
||
clearTimeout(priceLevelsRefreshTimer);
|
||
priceLevelsRefreshTimer = null;
|
||
}
|
||
|
||
await refreshPriceLevels({ force: true, noCache: true });
|
||
|
||
try {
|
||
const resp = await fetch('/api/configs/' + configUUID, {
|
||
method: 'PUT',
|
||
headers: {
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify(buildSavePayload())
|
||
});
|
||
|
||
if (!resp.ok) {
|
||
if (showNotification) {
|
||
showToast('Ошибка сохранения', 'error');
|
||
}
|
||
return;
|
||
}
|
||
|
||
const saved = await resp.json();
|
||
if (saved && saved.current_version_no) {
|
||
currentVersionNo = saved.current_version_no;
|
||
const versionEl = document.getElementById('breadcrumb-config-version');
|
||
if (versionEl) versionEl.textContent = 'v' + currentVersionNo;
|
||
}
|
||
hasUnsavedChanges = false;
|
||
clearAutosaveDraft();
|
||
exitSaveStarted = false;
|
||
|
||
if (showNotification) {
|
||
showToast('Сохранено', 'success');
|
||
}
|
||
} catch(e) {
|
||
if (showNotification) {
|
||
showToast('Ошибка сохранения', 'error');
|
||
}
|
||
}
|
||
}
|
||
|
||
// Helper function to extract filename from Content-Disposition header
|
||
function getFilenameFromResponse(resp) {
|
||
const contentDisposition = resp.headers.get('content-disposition');
|
||
if (!contentDisposition) return null;
|
||
const matches = contentDisposition.match(/filename="?([^"]+)"?/);
|
||
return matches && matches[1] ? matches[1] : null;
|
||
}
|
||
|
||
async function exportCSV() {
|
||
if (cart.length === 0) return;
|
||
|
||
try {
|
||
if (priceLevelsRefreshTimer) {
|
||
clearTimeout(priceLevelsRefreshTimer);
|
||
priceLevelsRefreshTimer = null;
|
||
}
|
||
await refreshPriceLevels({ force: true, noCache: true });
|
||
|
||
const exportItems = cart.map(item => ({
|
||
...item,
|
||
unit_price: getDisplayPrice(item),
|
||
}));
|
||
const article = getCurrentArticle();
|
||
const resp = await fetch('/api/export/csv', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({items: exportItems, name: configName, project_uuid: projectUUID, article: article, server_count: serverCount, pricelist_id: selectedPricelistIds.estimate || null})
|
||
});
|
||
|
||
const blob = await resp.blob();
|
||
const url = window.URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
const articleForName = article || 'BOM';
|
||
a.download = getFilenameFromResponse(resp) || ((configName || 'config') + ' ' + articleForName + '.csv');
|
||
a.click();
|
||
window.URL.revokeObjectURL(url);
|
||
} catch(e) {
|
||
showToast('Ошибка экспорта', 'error');
|
||
}
|
||
}
|
||
|
||
function formatLineTotalTooltip(qty, unitPrice) {
|
||
if (typeof unitPrice !== 'number' || unitPrice <= 0) return '';
|
||
const lineTotal = qty * unitPrice;
|
||
return `${formatNumberRu(qty)} * ${formatMoney(unitPrice)} = ${formatMoney(lineTotal)}`;
|
||
}
|
||
|
||
function toggleSection(contentId, iconId) {
|
||
const content = document.getElementById(contentId);
|
||
const icon = document.getElementById(iconId);
|
||
if (!content || !icon) return;
|
||
|
||
const isHidden = content.classList.toggle('hidden');
|
||
if (isHidden) {
|
||
icon.classList.add('-rotate-90');
|
||
} else {
|
||
icon.classList.remove('-rotate-90');
|
||
}
|
||
}
|
||
|
||
function toggleCartSummarySection() {
|
||
toggleSection('cart-summary-content', 'cart-summary-toggle-icon');
|
||
}
|
||
|
||
function toggleCustomPriceSection() {
|
||
toggleSection('custom-price-content', 'custom-price-toggle-icon');
|
||
}
|
||
|
||
function toggleSalePriceSection() {
|
||
toggleSection('sale-price-content', 'sale-price-toggle-icon');
|
||
}
|
||
|
||
function formatDiffPercent(baseTotal, compareTotal, compareLabel) {
|
||
if (typeof baseTotal !== 'number' || typeof compareTotal !== 'number' || compareTotal <= 0) {
|
||
return `N/A от ${compareLabel}`;
|
||
}
|
||
const pct = ((baseTotal - compareTotal) / compareTotal) * 100;
|
||
const sign = pct > 0 ? '+' : '';
|
||
return `${sign}${pct.toFixed(1)}% от ${compareLabel}`;
|
||
}
|
||
|
||
function getTotalClass(current, references) {
|
||
const validRefs = references.filter(v => typeof v === 'number' && v > 0);
|
||
if (typeof current !== 'number' || current <= 0 || validRefs.length === 0) {
|
||
return 'text-gray-900';
|
||
}
|
||
const avg = validRefs.reduce((sum, v) => sum + v, 0) / validRefs.length;
|
||
if (current < avg) return 'text-green-600';
|
||
if (current > avg) return 'text-red-600';
|
||
return 'text-gray-900';
|
||
}
|
||
|
||
function renderSalePriceTable() {
|
||
const body = document.getElementById('sale-prices-body');
|
||
const totalEstEl = document.getElementById('sale-total-est');
|
||
const totalWarehouseEl = document.getElementById('sale-total-warehouse');
|
||
const totalCompetitorEl = document.getElementById('sale-total-competitor');
|
||
if (!body || !totalEstEl || !totalWarehouseEl || !totalCompetitorEl) return;
|
||
|
||
if (cart.length === 0) {
|
||
body.innerHTML = '<tr><td colspan="5" class="px-3 py-3 text-center text-gray-500">Конфигурация пуста</td></tr>';
|
||
totalEstEl.textContent = '$0.00';
|
||
totalWarehouseEl.textContent = '$0.00';
|
||
totalCompetitorEl.textContent = '$0.00';
|
||
totalEstEl.title = '';
|
||
totalWarehouseEl.title = '';
|
||
totalCompetitorEl.title = '';
|
||
totalEstEl.className = 'px-3 py-2 text-right';
|
||
totalWarehouseEl.className = 'px-3 py-2 text-right';
|
||
totalCompetitorEl.className = 'px-3 py-2 text-right';
|
||
return;
|
||
}
|
||
|
||
const sortedCart = [...cart].sort((a, b) => {
|
||
const catA = (a.category || getCategoryFromLotName(a.lot_name)).toUpperCase();
|
||
const catB = (b.category || getCategoryFromLotName(b.lot_name)).toUpperCase();
|
||
const orderA = categoryOrderMap[catA] || 9999;
|
||
const orderB = categoryOrderMap[catB] || 9999;
|
||
return orderA - orderB;
|
||
});
|
||
|
||
let html = '';
|
||
let totalEstimate = 0;
|
||
let totalWarehouse = 0;
|
||
let totalCompetitor = 0;
|
||
|
||
sortedCart.forEach(item => {
|
||
const qty = item.quantity || 1;
|
||
const estPrice = item.estimate_price;
|
||
const warehousePrice = item.warehouse_price;
|
||
const competitorPrice = item.competitor_price;
|
||
|
||
if (typeof estPrice === 'number' && estPrice > 0) totalEstimate += estPrice * qty;
|
||
if (typeof warehousePrice === 'number' && warehousePrice > 0) totalWarehouse += warehousePrice * qty;
|
||
if (typeof competitorPrice === 'number' && competitorPrice > 0) totalCompetitor += competitorPrice * qty;
|
||
|
||
const estTooltip = formatLineTotalTooltip(qty, estPrice);
|
||
const warehouseTooltip = formatLineTotalTooltip(qty, warehousePrice);
|
||
const competitorTooltip = formatLineTotalTooltip(qty, competitorPrice);
|
||
|
||
html += `
|
||
<tr>
|
||
<td class="px-3 py-2 font-mono">${escapeHtml(item.lot_name)}</td>
|
||
<td class="px-3 py-2 text-right">${qty}</td>
|
||
<td class="px-3 py-2 text-right" title="${escapeHtml(estTooltip)}">${formatPriceOrNA(estPrice)}</td>
|
||
<td class="px-3 py-2 text-right" title="${escapeHtml(warehouseTooltip)}">${formatPriceOrNA(warehousePrice)}</td>
|
||
<td class="px-3 py-2 text-right" title="${escapeHtml(competitorTooltip)}">${formatPriceOrNA(competitorPrice)}</td>
|
||
</tr>
|
||
`;
|
||
});
|
||
|
||
body.innerHTML = html;
|
||
|
||
totalEstEl.textContent = formatMoney(totalEstimate);
|
||
totalWarehouseEl.textContent = formatMoney(totalWarehouse);
|
||
totalCompetitorEl.textContent = formatMoney(totalCompetitor);
|
||
|
||
totalEstEl.title =
|
||
`${formatDiffPercent(totalEstimate, totalWarehouse, 'склад')}\n` +
|
||
`${formatDiffPercent(totalEstimate, totalCompetitor, 'конкуренты')}`;
|
||
totalWarehouseEl.title =
|
||
`${formatDiffPercent(totalWarehouse, totalEstimate, 'est.price')}\n` +
|
||
`${formatDiffPercent(totalWarehouse, totalCompetitor, 'конкуренты')}`;
|
||
totalCompetitorEl.title =
|
||
`${formatDiffPercent(totalCompetitor, totalEstimate, 'est.price')}\n` +
|
||
`${formatDiffPercent(totalCompetitor, totalWarehouse, 'склад')}`;
|
||
|
||
totalEstEl.className = `px-3 py-2 text-right ${getTotalClass(totalEstimate, [totalWarehouse, totalCompetitor])}`;
|
||
totalWarehouseEl.className = `px-3 py-2 text-right ${getTotalClass(totalWarehouse, [totalEstimate, totalCompetitor])}`;
|
||
totalCompetitorEl.className = `px-3 py-2 text-right ${getTotalClass(totalCompetitor, [totalEstimate, totalWarehouse])}`;
|
||
}
|
||
|
||
// Custom price functionality
|
||
function calculateCustomPrice() {
|
||
const customPriceInput = document.getElementById('custom-price-input');
|
||
const 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) {
|
||
document.getElementById('adjusted-prices').classList.add('hidden');
|
||
document.getElementById('discount-info').classList.add('hidden');
|
||
return;
|
||
}
|
||
|
||
// Calculate discount percentage
|
||
const discountPercent = ((originalTotal - customPrice) / originalTotal) * 100;
|
||
const coefficient = customPrice / originalTotal;
|
||
|
||
// Show discount info
|
||
document.getElementById('discount-info').classList.remove('hidden');
|
||
document.getElementById('discount-percent').textContent = discountPercent.toFixed(1) + '%';
|
||
|
||
// Update discount color based on value
|
||
const discountEl = document.getElementById('discount-percent');
|
||
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>
|
||
`;
|
||
});
|
||
|
||
document.getElementById('adjusted-prices-body').innerHTML = html;
|
||
document.getElementById('adjusted-total-original').textContent = formatMoney(totalOriginal);
|
||
document.getElementById('adjusted-total-new').textContent = formatMoney(totalNew);
|
||
document.getElementById('adjusted-total-final').textContent = formatMoney(totalNew);
|
||
document.getElementById('adjusted-prices').classList.remove('hidden');
|
||
}
|
||
|
||
function clearCustomPrice() {
|
||
document.getElementById('custom-price-input').value = '';
|
||
document.getElementById('adjusted-prices').classList.add('hidden');
|
||
document.getElementById('discount-info').classList.add('hidden');
|
||
triggerAutoSave();
|
||
}
|
||
|
||
async function exportCSVWithCustomPrice() {
|
||
if (cart.length === 0) return;
|
||
if (priceLevelsRefreshTimer) {
|
||
clearTimeout(priceLevelsRefreshTimer);
|
||
priceLevelsRefreshTimer = null;
|
||
}
|
||
await refreshPriceLevels({ force: true, noCache: true });
|
||
|
||
const customPrice = parseFloat(document.getElementById('custom-price-input').value) || 0;
|
||
const originalTotal = cart.reduce((sum, item) => sum + (getDisplayPrice(item) * item.quantity), 0);
|
||
|
||
if (customPrice <= 0 || originalTotal <= 0) {
|
||
showToast('Введите целевую цену', 'error');
|
||
return;
|
||
}
|
||
|
||
const coefficient = customPrice / originalTotal;
|
||
|
||
// Create adjusted cart
|
||
const adjustedCart = cart.map(item => ({
|
||
...item,
|
||
unit_price: parseFloat((getDisplayPrice(item) * coefficient).toFixed(2))
|
||
}));
|
||
|
||
try {
|
||
const resp = await fetch('/api/export/csv', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({items: adjustedCart, name: configName, project_uuid: projectUUID})
|
||
});
|
||
|
||
const blob = await resp.blob();
|
||
const url = window.URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = getFilenameFromResponse(resp) || (configName || 'config') + '.csv';
|
||
a.click();
|
||
window.URL.revokeObjectURL(url);
|
||
} catch(e) {
|
||
showToast('Ошибка экспорта', 'error');
|
||
}
|
||
}
|
||
|
||
async function refreshPrices() {
|
||
// RBAC disabled - no token check required
|
||
if (!configUUID) return;
|
||
if (disablePriceRefresh) {
|
||
showToast('Обновление цен отключено в настройках', 'error');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const resp = await fetch('/api/configs/' + configUUID + '/refresh-prices', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json'
|
||
}
|
||
});
|
||
|
||
if (!resp.ok) {
|
||
showToast('Ошибка обновления цен', 'error');
|
||
return;
|
||
}
|
||
|
||
const config = await resp.json();
|
||
|
||
// Update cart with new prices
|
||
if (config.items && config.items.length > 0) {
|
||
cart = config.items.map(item => ({
|
||
lot_name: item.lot_name,
|
||
quantity: item.quantity,
|
||
unit_price: item.unit_price,
|
||
estimate_price: item.unit_price,
|
||
warehouse_price: null,
|
||
competitor_price: null,
|
||
description: item.description || '',
|
||
category: item.category || getCategoryFromLotName(item.lot_name)
|
||
}));
|
||
}
|
||
|
||
// Update price update date
|
||
if (config.price_updated_at) {
|
||
updatePriceUpdateDate(config.price_updated_at);
|
||
}
|
||
if (config.pricelist_id) {
|
||
if (selectedPricelistIds.estimate) {
|
||
selectedPricelistIds.estimate = config.pricelist_id;
|
||
} else {
|
||
resolvedAutoPricelistIds.estimate = Number(config.pricelist_id);
|
||
}
|
||
if (!activePricelistsBySource.estimate.some(opt => Number(opt.id) === Number(config.pricelist_id))) {
|
||
await loadActivePricelists();
|
||
}
|
||
syncPriceSettingsControls();
|
||
renderPricelistSettingsSummary();
|
||
if (selectedPricelistIds.estimate) {
|
||
persistLocalPriceSettings();
|
||
}
|
||
}
|
||
|
||
// Re-render UI
|
||
await refreshPriceLevels();
|
||
renderTab();
|
||
updateCartUI();
|
||
|
||
showToast('Цены обновлены', 'success');
|
||
} catch(e) {
|
||
showToast('Ошибка обновления цен', 'error');
|
||
}
|
||
}
|
||
|
||
function updatePriceUpdateDate(dateStr) {
|
||
if (!dateStr) {
|
||
document.getElementById('price-update-date').textContent = '';
|
||
return;
|
||
}
|
||
|
||
const date = new Date(dateStr);
|
||
const now = new Date();
|
||
const diffMs = now - date;
|
||
const diffMins = Math.floor(diffMs / 60000);
|
||
const diffHours = Math.floor(diffMs / 3600000);
|
||
const diffDays = Math.floor(diffMs / 86400000);
|
||
|
||
let timeAgo;
|
||
if (diffMins < 1) {
|
||
timeAgo = 'только что';
|
||
} else if (diffMins < 60) {
|
||
timeAgo = diffMins + ' мин. назад';
|
||
} else if (diffHours < 24) {
|
||
timeAgo = diffHours + ' ч. назад';
|
||
} else if (diffDays < 7) {
|
||
timeAgo = diffDays + ' дн. назад';
|
||
} else {
|
||
timeAgo = date.toLocaleDateString('ru-RU');
|
||
}
|
||
|
||
document.getElementById('price-update-date').textContent = 'Обновлено: ' + timeAgo;
|
||
}
|
||
|
||
// ==================== TOP-LEVEL TABS ====================
|
||
|
||
let currentTopTab = 'estimate';
|
||
|
||
function switchTopTab(tab) {
|
||
currentTopTab = tab;
|
||
const tabs = ['estimate', 'bom', 'pricing'];
|
||
tabs.forEach(t => {
|
||
const btn = document.getElementById('top-tab-' + t);
|
||
const section = document.getElementById('top-section-' + t);
|
||
if (t === tab) {
|
||
btn.classList.remove('border-transparent', 'text-gray-500');
|
||
btn.classList.add('border-blue-600', 'text-blue-600');
|
||
section.classList.remove('hidden');
|
||
} else {
|
||
btn.classList.remove('border-blue-600', 'text-blue-600');
|
||
btn.classList.add('border-transparent', 'text-gray-500');
|
||
section.classList.add('hidden');
|
||
}
|
||
});
|
||
if (tab === 'pricing') {
|
||
renderPricingTab();
|
||
}
|
||
}
|
||
|
||
// ==================== BOM ВЕНДОРА ====================
|
||
|
||
let bomRows = []; // [{vendor_pn, quantity, description, unit_price, total_price, resolved_lot, resolution_source}]
|
||
|
||
// Parse a price string handling $, spaces, comma-as-decimal and comma-as-thousands.
|
||
function parsePastePrice(s) {
|
||
if (!s) return null;
|
||
let v = s.replace(/[$\s]/g, '');
|
||
// Determine decimal separator: if ends with ",dd" treat comma as decimal
|
||
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;
|
||
}
|
||
|
||
// Auto-detect which column index serves as PN, qty, description, price.
|
||
function detectBOMColumns(rows) {
|
||
const ncols = Math.max(...rows.map(r => r.length));
|
||
let qtyCol = -1, pnCol = -1, descCol = -1, priceCol = -1;
|
||
|
||
// Price: last column where ≥70% of values parse as non-null price
|
||
for (let c = ncols - 1; c >= 0; c--) {
|
||
const hits = rows.filter(r => parsePastePrice(r[c] || '') !== null).length;
|
||
if (hits >= rows.length * 0.7) { priceCol = c; break; }
|
||
}
|
||
|
||
// Qty: first column (before price) where all values are integers 1..9999
|
||
for (let c = 0; c < ncols; c++) {
|
||
if (c === priceCol) continue;
|
||
const allInt = rows.every(r => /^\d{1,4}$/.test((r[c] || '').trim()));
|
||
if (allInt) { qtyCol = c; break; }
|
||
}
|
||
|
||
// PN: last column before qty that contains no spaces and looks like a product code
|
||
if (qtyCol > 0) {
|
||
for (let c = qtyCol - 1; c >= 0; c--) {
|
||
const looksPN = rows.every(r => {
|
||
const v = (r[c] || '').trim();
|
||
return v.length > 0 && v.length <= 60 && !/\s{2,}/.test(v);
|
||
});
|
||
if (looksPN) { pnCol = c; break; }
|
||
}
|
||
}
|
||
if (pnCol === -1) pnCol = 0;
|
||
|
||
// Description: column after qty with longest average text (excluding price col)
|
||
let bestLen = -1;
|
||
for (let c = (qtyCol !== -1 ? qtyCol + 1 : 1); c < ncols; c++) {
|
||
if (c === priceCol) continue;
|
||
const avg = rows.reduce((s, r) => s + (r[c] || '').length, 0) / rows.length;
|
||
if (avg > bestLen) { bestLen = avg; descCol = c; }
|
||
}
|
||
|
||
return { pnCol, qtyCol, descCol, priceCol };
|
||
}
|
||
|
||
function handleBOMPaste(event) {
|
||
event.preventDefault();
|
||
const text = event.clipboardData.getData('text/plain');
|
||
if (!text) return;
|
||
|
||
const lines = text.trim().split(/\r?\n/).filter(l => l.trim());
|
||
if (!lines.length) return;
|
||
|
||
// Split all rows
|
||
let rows = lines.map(l => l.split('\t').map(c => c.trim()));
|
||
|
||
// Skip header row: if qty column candidate on row 0 is not a number
|
||
// We detect columns on all rows first (without header), then recheck row 0
|
||
const { pnCol, qtyCol, descCol, priceCol } = detectBOMColumns(rows);
|
||
|
||
// Drop header if row[0][qtyCol] is not numeric
|
||
if (qtyCol !== -1 && rows.length > 1 && !/^\d+$/.test((rows[0][qtyCol] || '').trim())) {
|
||
rows = rows.slice(1);
|
||
}
|
||
|
||
const parsed = [];
|
||
for (const cols of rows) {
|
||
const pn = pnCol !== -1 ? (cols[pnCol] || '').trim() : '';
|
||
if (!pn) continue;
|
||
|
||
const rawQty = qtyCol !== -1 ? (cols[qtyCol] || '').trim() : '1';
|
||
const qty = parseInt(rawQty) || 1;
|
||
|
||
const description = descCol !== -1 ? (cols[descCol] || '').trim() : '';
|
||
|
||
let unit_price = null, total_price = null;
|
||
if (priceCol !== -1) {
|
||
unit_price = parsePastePrice(cols[priceCol] || '');
|
||
if (unit_price !== null) total_price = unit_price * qty;
|
||
}
|
||
|
||
parsed.push({
|
||
sort_order: (parsed.length + 1) * 10,
|
||
vendor_pn: pn,
|
||
quantity: qty,
|
||
description,
|
||
unit_price,
|
||
total_price,
|
||
resolved_lot: '',
|
||
resolution_source: 'unresolved',
|
||
manual_lot: ''
|
||
});
|
||
}
|
||
|
||
if (!parsed.length) {
|
||
alert('Не удалось распознать данные. Убедитесь, что скопированы строки из Excel.');
|
||
return;
|
||
}
|
||
|
||
bomRows = parsed;
|
||
resolveBOM();
|
||
}
|
||
|
||
async function resolveBOM() {
|
||
if (!configUUID) 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
|
||
}));
|
||
|
||
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();
|
||
// Merge resolution results back into bomRows
|
||
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';
|
||
}
|
||
});
|
||
}
|
||
|
||
// Push unresolved PNs to server partnumber_seen registry (fire-and-forget)
|
||
const unseen = bomRows
|
||
.filter(r => !r.resolved_lot || r.resolution_source === 'unresolved')
|
||
.map(r => ({ partnumber: r.vendor_pn, description: r.description || '' }));
|
||
if (unseen.length) {
|
||
fetch('/api/sync/partnumber-seen', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({items: unseen})
|
||
}).catch(() => {});
|
||
}
|
||
} catch (e) {
|
||
console.warn('Resolution failed:', e);
|
||
}
|
||
|
||
renderBOMTable();
|
||
}
|
||
|
||
function renderBOMTable() {
|
||
// Rebuild datalist for LOT autocomplete from current allComponents
|
||
let dl = document.getElementById('lot-autocomplete-list');
|
||
if (!dl) { dl = document.createElement('datalist'); dl.id = 'lot-autocomplete-list'; document.body.appendChild(dl); }
|
||
dl.innerHTML = _bomLots().map(l => `<option value="${escapeHtml(l)}">`).join('');
|
||
|
||
const tbody = document.getElementById('bom-table-body');
|
||
const cart = window._currentCart || [];
|
||
|
||
// Build cart map: lot_name → quantity
|
||
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++;
|
||
|
||
let rowClass = '';
|
||
if (isUnresolved) rowClass = 'bg-red-50';
|
||
else if (qtyMismatch) rowClass = 'bg-yellow-50';
|
||
else if (notInCart) rowClass = 'bg-orange-50';
|
||
|
||
tr.className = rowClass;
|
||
|
||
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;"
|
||
onchange="if(_bomLotValid(this.value)){bomRows[${idx}].manual_lot=this.value;resolveBOM();}else{this.value=bomRows[${idx}].manual_lot||'';}"
|
||
list="lot-autocomplete-list">`;
|
||
} 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 = `<span class="font-mono text-xs">${row.resolved_lot}</span>${suffix}`;
|
||
}
|
||
|
||
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);
|
||
});
|
||
|
||
// Stats
|
||
const statsEl = document.getElementById('bom-stats');
|
||
statsEl.textContent = `Строк: ${bomRows.length} | Не сопоставлено: ${unresolved} | Расхождений: ${mismatches}`;
|
||
|
||
document.getElementById('bom-table-container').classList.remove('hidden');
|
||
|
||
// Also update pricing tab if visible
|
||
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 = [];
|
||
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 saveBOM() {
|
||
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,
|
||
resolved_lot_name: r.resolved_lot,
|
||
resolution_source: r.resolution_source,
|
||
manual_lot_suggestion: r.manual_lot || null
|
||
}));
|
||
try {
|
||
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());
|
||
showToast('BOM сохранён', 'success');
|
||
} catch (e) {
|
||
showToast('Ошибка сохранения BOM: ' + e.message, 'error');
|
||
}
|
||
}
|
||
|
||
async function applyBOMToEstimate() {
|
||
if (!bomRows.length) return;
|
||
const resolved = bomRows.filter(r => r.resolved_lot);
|
||
if (!resolved.length) {
|
||
alert('Нет сопоставленных строк. Сначала настройте LOT для всех позиций.');
|
||
return;
|
||
}
|
||
|
||
// Aggregate quantities
|
||
const lotMap = {};
|
||
resolved.forEach(r => {
|
||
if (!lotMap[r.resolved_lot]) lotMap[r.resolved_lot] = 0;
|
||
lotMap[r.resolved_lot] += r.quantity;
|
||
});
|
||
|
||
const items = Object.entries(lotMap).map(([lot, qty]) => ({
|
||
lot_name: lot,
|
||
quantity: qty,
|
||
unit_price: 0
|
||
}));
|
||
|
||
if (!confirm(`Пересчитать Estimate? Текущая корзина будет заменена ${items.length} позициями из BOM.`)) 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) => ({
|
||
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: r.resolved_lot_name || '',
|
||
resolution_source: r.resolution_source || 'unresolved',
|
||
manual_lot: r.manual_lot_suggestion || ''
|
||
}));
|
||
renderBOMTable();
|
||
}
|
||
} catch (e) {
|
||
console.warn('Failed to load vendor spec:', e);
|
||
}
|
||
}
|
||
|
||
// ==================== ЦЕНООБРАЗОВАНИЕ ====================
|
||
|
||
async function renderPricingTab() {
|
||
const tbody = document.getElementById('pricing-table-body');
|
||
const tfoot = document.getElementById('pricing-table-foot');
|
||
|
||
const cart = window._currentCart || [];
|
||
const compMap = {};
|
||
(window._bomAllComponents || allComponents).forEach(c => { compMap[c.lot_name] = c; });
|
||
|
||
// Collect LOTs to price: from BOM rows (resolved) or from cart
|
||
let itemsForPriceLevels = [];
|
||
if (bomRows.length) {
|
||
const seen = new Set();
|
||
bomRows.forEach(row => {
|
||
const lot = row.resolved_lot;
|
||
if (lot && row.resolution_source !== 'unresolved' && !seen.has(lot)) {
|
||
seen.add(lot);
|
||
itemsForPriceLevels.push({ lot_name: lot, quantity: row.quantity });
|
||
}
|
||
});
|
||
} else {
|
||
itemsForPriceLevels = cart.map(item => ({ lot_name: item.lot_name, quantity: item.quantity }));
|
||
}
|
||
|
||
// Fetch fresh price levels for these LOTs
|
||
const priceMap = {}; // lot_name → {estimate_price, ...}
|
||
if (itemsForPriceLevels.length) {
|
||
try {
|
||
const payload = {
|
||
items: itemsForPriceLevels,
|
||
pricelist_ids: Object.fromEntries(
|
||
Object.entries(selectedPricelistIds)
|
||
.filter(([, id]) => typeof id === 'number' && id > 0)
|
||
)
|
||
};
|
||
const resp = await fetch('/api/quote/price-levels', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(payload)
|
||
});
|
||
if (resp.ok) {
|
||
const data = await resp.json();
|
||
(data.items || []).forEach(i => { priceMap[i.lot_name] = i; });
|
||
}
|
||
} catch(e) { /* silent — pricing tab renders with available data */ }
|
||
}
|
||
|
||
let totalVendor = 0, totalEstimate = 0;
|
||
let hasVendor = false, hasEstimate = false;
|
||
|
||
tbody.innerHTML = '';
|
||
|
||
if (!bomRows.length) {
|
||
if (!cart.length) {
|
||
tbody.innerHTML = '<tr><td colspan="9" class="px-3 py-8 text-center text-gray-400">Нет данных для отображения</td></tr>';
|
||
tfoot.classList.add('hidden');
|
||
return;
|
||
}
|
||
cart.forEach(item => {
|
||
const tr = document.createElement('tr');
|
||
tr.classList.add('pricing-row');
|
||
const pl = priceMap[item.lot_name];
|
||
const estUnit = (pl && pl.estimate_price > 0) ? pl.estimate_price : (item.unit_price || 0);
|
||
const estimateTotal = estUnit * item.quantity;
|
||
if (estimateTotal > 0) { totalEstimate += estimateTotal; hasEstimate = true; }
|
||
tr.dataset.est = estimateTotal;
|
||
const desc = (compMap[item.lot_name] || {}).description || '';
|
||
tr.dataset.vendorOrig = '';
|
||
tr.innerHTML = `
|
||
<td class="px-3 py-1.5 text-xs">${escapeHtml(item.lot_name)}</td>
|
||
<td class="px-3 py-1.5 text-xs text-gray-400">—</td>
|
||
<td class="px-3 py-1.5 text-xs text-gray-500 truncate max-w-xs">${escapeHtml(desc)}</td>
|
||
<td class="px-3 py-1.5 text-right">${item.quantity}</td>
|
||
<td class="px-3 py-1.5 text-right text-xs">${estimateTotal > 0 ? formatCurrency(estimateTotal) : '—'}</td>
|
||
<td class="px-3 py-1.5 text-right text-xs text-gray-400 pricing-vendor-price">—</td>
|
||
<td class="px-3 py-1.5 text-right text-xs text-gray-400">—</td>
|
||
<td class="px-3 py-1.5 text-right text-xs text-gray-400">—</td>
|
||
`;
|
||
tbody.appendChild(tr);
|
||
});
|
||
} else {
|
||
bomRows.forEach(row => {
|
||
const tr = document.createElement('tr');
|
||
tr.classList.add('pricing-row');
|
||
const isUnresolved = !row.resolved_lot || row.resolution_source === 'unresolved';
|
||
const pl = row.resolved_lot ? priceMap[row.resolved_lot] : null;
|
||
const estimateUnit = (pl && pl.estimate_price > 0) ? pl.estimate_price : null;
|
||
const rowEst = estimateUnit != null ? estimateUnit * row.quantity : 0;
|
||
|
||
const vendorTotal = row.total_price != null ? row.total_price : (row.unit_price != null ? row.unit_price * row.quantity : null);
|
||
if (vendorTotal != null) { totalVendor += vendorTotal; hasVendor = true; }
|
||
if (estimateUnit != null) { totalEstimate += rowEst; hasEstimate = true; }
|
||
|
||
tr.dataset.est = rowEst;
|
||
tr.dataset.vendorOrig = vendorTotal != null ? vendorTotal : '';
|
||
const desc = row.description || (row.resolved_lot ? ((compMap[row.resolved_lot] || {}).description || '') : '');
|
||
tr.innerHTML = `
|
||
<td class="px-3 py-1.5 text-xs">${isUnresolved ? '<span class="text-red-500">н/д</span>' : escapeHtml(row.resolved_lot)}</td>
|
||
<td class="px-3 py-1.5 font-mono text-xs">${escapeHtml(row.vendor_pn)}</td>
|
||
<td class="px-3 py-1.5 text-xs text-gray-500 truncate max-w-xs">${escapeHtml(desc)}</td>
|
||
<td class="px-3 py-1.5 text-right">${row.quantity}</td>
|
||
<td class="px-3 py-1.5 text-right text-xs">${estimateUnit != null ? formatCurrency(rowEst) : '—'}</td>
|
||
<td class="px-3 py-1.5 text-right text-xs pricing-vendor-price">${vendorTotal != null ? formatCurrency(vendorTotal) : '—'}</td>
|
||
<td class="px-3 py-1.5 text-right text-xs text-gray-400">—</td>
|
||
<td class="px-3 py-1.5 text-right text-xs text-gray-400">—</td>
|
||
`;
|
||
tbody.appendChild(tr);
|
||
});
|
||
}
|
||
|
||
// Totals row
|
||
document.getElementById('pricing-total-vendor').textContent = hasVendor ? formatCurrency(totalVendor) : '—';
|
||
document.getElementById('pricing-total-estimate').textContent = hasEstimate ? formatCurrency(totalEstimate) : '—';
|
||
document.getElementById('pricing-total-warehouse').textContent = '—';
|
||
tfoot.classList.remove('hidden');
|
||
|
||
// Update custom price proportional breakdown
|
||
onPricingCustomPriceInput();
|
||
}
|
||
|
||
function setPricingCustomPriceFromVendor() {
|
||
// Apply per-row BOM prices directly (not proportional redistribution)
|
||
const rows = document.querySelectorAll('#pricing-table-body tr.pricing-row');
|
||
const vendorCells = document.querySelectorAll('#pricing-table-body .pricing-vendor-price');
|
||
let total = 0;
|
||
let hasAny = false;
|
||
|
||
rows.forEach((tr, i) => {
|
||
const cell = vendorCells[i];
|
||
if (!cell) return;
|
||
const orig = tr.dataset.vendorOrig;
|
||
if (orig !== '') {
|
||
const v = parseFloat(orig);
|
||
cell.textContent = formatCurrency(v);
|
||
cell.classList.remove('text-blue-700', 'text-gray-400');
|
||
total += v;
|
||
hasAny = true;
|
||
} else {
|
||
cell.textContent = '—';
|
||
cell.classList.add('text-gray-400');
|
||
cell.classList.remove('text-blue-700');
|
||
}
|
||
});
|
||
|
||
document.getElementById('pricing-total-vendor').textContent = hasAny ? formatCurrency(total) : '—';
|
||
document.getElementById('pricing-custom-price').value = hasAny ? total.toFixed(2) : '';
|
||
|
||
// Update discount info only
|
||
const rows2 = document.querySelectorAll('#pricing-table-body tr.pricing-row');
|
||
let estimateTotal = 0;
|
||
rows2.forEach(tr => { estimateTotal += parseFloat(tr.dataset.est) || 0; });
|
||
const discountEl = document.getElementById('pricing-discount-info');
|
||
const pctEl = document.getElementById('pricing-discount-pct');
|
||
if (hasAny && total > 0 && estimateTotal > 0) {
|
||
pctEl.textContent = ((estimateTotal - total) / estimateTotal * 100).toFixed(1) + '%';
|
||
discountEl.classList.remove('hidden');
|
||
} else {
|
||
discountEl.classList.add('hidden');
|
||
}
|
||
}
|
||
|
||
function onPricingCustomPriceInput() {
|
||
const customPrice = parseFloat(document.getElementById('pricing-custom-price').value) || 0;
|
||
|
||
const rows = document.querySelectorAll('#pricing-table-body tr.pricing-row');
|
||
let estimateTotal = 0;
|
||
rows.forEach(tr => { estimateTotal += parseFloat(tr.dataset.est) || 0; });
|
||
|
||
const vendorCells = document.querySelectorAll('#pricing-table-body .pricing-vendor-price');
|
||
const totalVendorEl = document.getElementById('pricing-total-vendor');
|
||
|
||
if (customPrice > 0 && estimateTotal > 0) {
|
||
// Proportionally redistribute custom price → Цена вендора cells
|
||
let assigned = 0;
|
||
rows.forEach((tr, i) => {
|
||
const est = parseFloat(tr.dataset.est) || 0;
|
||
const cell = vendorCells[i];
|
||
if (!cell) return;
|
||
let share;
|
||
if (i === rows.length - 1) {
|
||
share = customPrice - assigned;
|
||
} else {
|
||
share = Math.round((est / estimateTotal) * customPrice * 100) / 100;
|
||
assigned += share;
|
||
}
|
||
cell.textContent = formatCurrency(share);
|
||
cell.classList.add('text-blue-700');
|
||
cell.classList.remove('text-gray-400');
|
||
});
|
||
totalVendorEl.textContent = formatCurrency(customPrice);
|
||
} else {
|
||
// Restore original vendor prices from BOM
|
||
rows.forEach((tr, i) => {
|
||
const cell = vendorCells[i];
|
||
if (!cell) return;
|
||
const orig = tr.dataset.vendorOrig;
|
||
if (orig !== '') {
|
||
cell.textContent = formatCurrency(parseFloat(orig));
|
||
cell.classList.remove('text-blue-700', 'text-gray-400');
|
||
} else {
|
||
cell.textContent = '—';
|
||
cell.classList.add('text-gray-400');
|
||
cell.classList.remove('text-blue-700');
|
||
}
|
||
});
|
||
// Recompute vendor total from originals
|
||
let origTotal = 0; let hasOrig = false;
|
||
rows.forEach(tr => { if (tr.dataset.vendorOrig !== '') { origTotal += parseFloat(tr.dataset.vendorOrig) || 0; hasOrig = true; } });
|
||
totalVendorEl.textContent = hasOrig ? formatCurrency(origTotal) : '—';
|
||
}
|
||
|
||
// Discount info
|
||
const discountEl = document.getElementById('pricing-discount-info');
|
||
const pctEl = document.getElementById('pricing-discount-pct');
|
||
if (customPrice > 0 && estimateTotal > 0) {
|
||
const discount = ((estimateTotal - customPrice) / estimateTotal * 100).toFixed(1);
|
||
pctEl.textContent = discount + '%';
|
||
discountEl.classList.remove('hidden');
|
||
} else {
|
||
discountEl.classList.add('hidden');
|
||
}
|
||
}
|
||
|
||
function exportPricingCSV() {
|
||
const rows = document.querySelectorAll('#pricing-table-body tr.pricing-row');
|
||
if (!rows.length) { showToast('Нет данных для экспорта', 'error'); return; }
|
||
|
||
const csvEscape = v => {
|
||
if (v == null) return '';
|
||
const s = String(v).replace(/"/g, '""');
|
||
return /[,"\n]/.test(s) ? `"${s}"` : s;
|
||
};
|
||
|
||
const headers = ['LOT', 'PN вендора', 'Описание', 'Кол-во', 'Estimate', 'Цена вендора', 'Склад', 'Конкуренты'];
|
||
const lines = [headers.map(csvEscape).join(',')];
|
||
|
||
rows.forEach(tr => {
|
||
const cells = tr.querySelectorAll('td');
|
||
const rowData = Array.from(cells).map(td => td.textContent.trim());
|
||
lines.push(rowData.map(csvEscape).join(','));
|
||
});
|
||
|
||
// Totals row
|
||
const estTotal = document.getElementById('pricing-total-estimate').textContent.trim();
|
||
const vendorTotal = document.getElementById('pricing-total-vendor').textContent.trim();
|
||
lines.push(['', '', '', 'Итого', estTotal, vendorTotal, '', ''].map(csvEscape).join(','));
|
||
|
||
const blob = new Blob(['\uFEFF' + lines.join('\r\n')], {type: 'text/csv;charset=utf-8;'});
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = `pricing_${configUUID || 'export'}.csv`;
|
||
a.click();
|
||
URL.revokeObjectURL(url);
|
||
}
|
||
|
||
function escapeHtml(str) {
|
||
if (!str) return '';
|
||
return str.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||
}
|
||
|
||
function formatCurrency(val) {
|
||
if (val == null) return '—';
|
||
return val.toLocaleString('ru-RU', {minimumFractionDigits: 2, maximumFractionDigits: 2});
|
||
}
|
||
|
||
|
||
</script>
|
||
{{end}}
|
||
|
||
{{template "base" .}}
|