Files
QuoteForge/web/templates/index.html
2026-02-13 19:27:48 +03:00

2340 lines
93 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{{define "title"}}QuoteForge - Конфигуратор{{end}}
{{define "content"}}
<div class="space-y-4">
<!-- Header with config name and back button -->
<div class="flex items-center justify-between">
<div class="flex items-center space-x-4">
<a href="/projects" class="text-gray-500 hover:text-gray-700" title="Все проекты">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 9.75L12 3l9 6.75v9A2.25 2.25 0 0118.75 21h-13.5A2.25 2.25 0 013 18.75v-9z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 21v-6h6v6"></path>
</svg>
</a>
<div class="text-2xl font-bold flex items-center gap-2" id="config-breadcrumbs">
<a id="breadcrumb-project-code-link" href="/projects" class="text-blue-700 hover:underline">
<span id="breadcrumb-project-code"></span>
</a>
<span class="text-gray-400">-</span>
<a id="breadcrumb-project-variant-link" href="/projects" class="text-blue-700 hover:underline">
<span id="breadcrumb-project-variant">main</span>
</a>
<span class="text-gray-400">-</span>
<span id="breadcrumb-config-name">Конфигуратор</span>
<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>
<!-- 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>
<!-- Custom price section -->
<div id="custom-price-section" class="bg-white rounded-lg shadow overflow-hidden">
<button type="button"
onclick="toggleCustomPriceSection()"
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="custom-price-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="custom-price-content" class="p-4">
<div class="flex items-center gap-4 mb-4">
<div class="flex-1">
<label class="block text-sm text-gray-600 mb-1">Введите целевую цену</label>
<div class="flex items-center gap-2">
<span class="text-gray-500">$</span>
<input type="number" id="custom-price-input" step="0.01" min="0"
placeholder="0.00"
class="flex-1 px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500"
oninput="calculateCustomPrice(); triggerAutoSave();">
<button onclick="clearCustomPrice()" class="px-3 py-2 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>
<div id="discount-info" class="text-right hidden">
<div class="text-sm text-gray-600">Скидка</div>
<div class="text-2xl font-bold text-green-600" id="discount-percent">0%</div>
</div>
</div>
<!-- Adjusted prices table -->
<div id="adjusted-prices" class="hidden">
<div class="border-t pt-3">
<h4 class="text-sm font-medium text-gray-700 mb-2">Скорректированные цены</h4>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-gray-50">
<tr>
<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">Кол-во</th>
<th class="px-3 py-2 text-right 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">Стало</th>
<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase">Итого</th>
</tr>
</thead>
<tbody id="adjusted-prices-body" class="divide-y"></tbody>
<tfoot class="bg-gray-50 font-medium">
<tr>
<td class="px-3 py-2" colspan="2">Итого</td>
<td class="px-3 py-2 text-right" id="adjusted-total-original">$0.00</td>
<td class="px-3 py-2 text-right text-green-600" id="adjusted-total-new">$0.00</td>
<td class="px-3 py-2 text-right text-green-600" id="adjusted-total-final">$0.00</td>
</tr>
</tfoot>
</table>
</div>
</div>
<div class="mt-3 flex justify-end">
<button onclick="exportCSVWithCustomPrice()" class="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700">
Экспорт CSV со скидкой
</button>
</div>
</div>
</div>
</div>
<!-- Sale price section -->
<div id="sale-price-section" class="bg-white rounded-lg shadow overflow-hidden">
<button type="button"
onclick="toggleSalePriceSection()"
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="sale-price-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="sale-price-content" class="p-4">
<div id="sale-prices" class="border-t pt-3">
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-gray-50">
<tr>
<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">Кол-во</th>
<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase">Est. Price</th>
<th class="px-3 py-2 text-right 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">Конкуренты</th>
</tr>
</thead>
<tbody id="sale-prices-body" class="divide-y"></tbody>
<tfoot class="bg-gray-50 font-medium">
<tr>
<td class="px-3 py-2">Итого</td>
<td class="px-3 py-2 text-right"></td>
<td class="px-3 py-2 text-right" id="sale-total-est">$0.00</td>
<td class="px-3 py-2 text-right" id="sale-total-warehouse">$0.00</td>
<td class="px-3 py-2 text-right" id="sale-total-competitor">$0.00</td>
</tr>
</tfoot>
</table>
</div>
</div>
</div>
</div>
</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 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;
configEl.textContent = configName || 'Конфигурация';
versionEl.textContent = 'v1';
}
let currentTab = 'base';
let allComponents = [];
let cart = [];
let categoryOrderMap = {}; // Category code -> display_order mapping
let autoSaveTimeout = null; // Timeout for debounced autosave
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 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)) + '%)';
}
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]) {
selectedPricelistIds[source] = data.resolved_pricelist_ids[source];
}
});
syncPriceSettingsControls();
renderPricelistSettingsSummary();
persistLocalPriceSettings();
}
} 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() {
const id = selectedPricelistIds.warehouse;
if (Number.isFinite(id) && id > 0) return Number(id);
const fallback = activePricelistsBySource.warehouse?.[0]?.id;
if (Number.isFinite(fallback) && fallback > 0) return Number(fallback);
return null;
}
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;
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();
}
});
});
async function loadAllComponents() {
try {
const resp = await fetch('/api/components?per_page=5000');
const data = await resp.json();
allComponents = data.components || [];
} catch(e) {
console.error('Failed to load components', e);
allComponents = [];
}
}
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] = activePricelistsBySource[source].length > 0
? Number(activePricelistsBySource[source][0].id)
: 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;
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() {
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();
if (!model || !selectedPricelistIds.estimate || 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: selectedPricelistIds.estimate,
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 triggerAutoSave() {
// Debounce autosave - wait 1 second after last change
if (autoSaveTimeout) {
clearTimeout(autoSaveTimeout);
}
autoSaveTimeout = setTimeout(() => {
saveConfig(false); // false = don't show notification
}, 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 });
// Get custom price if set
const customPriceInput = document.getElementById('custom-price-input');
const customPriceValue = parseFloat(customPriceInput.value);
const customPrice = customPriceValue > 0 ? customPriceValue : null;
// Get server count
const serverCountValue = serverCount;
try {
const resp = await fetch('/api/configs/' + configUUID, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
name: configName,
items: cart,
custom_price: customPrice,
notes: '',
server_count: serverCountValue,
server_model: serverModelForQuote,
support_code: supportCode,
article: getCurrentArticle(),
pricelist_id: selectedPricelistIds.estimate,
only_in_stock: onlyInStock
})
});
if (!resp.ok) {
if (showNotification) {
showToast('Ошибка сохранения', 'error');
}
return;
}
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})
});
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) {
selectedPricelistIds.estimate = config.pricelist_id;
if (!activePricelistsBySource.estimate.some(opt => Number(opt.id) === Number(config.pricelist_id))) {
await loadActivePricelists();
}
syncPriceSettingsControls();
renderPricelistSettingsSummary();
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;
}
</script>
{{end}}
{{template "base" .}}