2099 lines
83 KiB
HTML
2099 lines
83 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="/configs" 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="M15 19l-7-7 7-7"></path>
|
||
</svg>
|
||
</a>
|
||
<h1 class="text-2xl font-bold">
|
||
<span id="config-name">Конфигуратор</span>
|
||
</h1>
|
||
</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="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 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 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();
|
||
|
||
// 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;
|
||
document.getElementById('config-name').textContent = config.name;
|
||
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)
|
||
}));
|
||
}
|
||
|
||
// 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 = `
|
||
<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;
|
||
}
|
||
|
||
// Autocomplete for single select (Base tab)
|
||
function showAutocomplete(category, input) {
|
||
autocompleteInput = input;
|
||
autocompleteCategory = category;
|
||
autocompleteMode = 'single';
|
||
autocompleteIndex = -1;
|
||
filterAutocomplete(category, input.value);
|
||
}
|
||
|
||
function filterAutocomplete(category, search) {
|
||
const components = getComponentsForCategory(category);
|
||
const searchLower = search.toLowerCase();
|
||
|
||
autocompleteFiltered = components.filter(c => {
|
||
if (!c.current_price) 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;
|
||
|
||
cart.push({
|
||
lot_name: comp.lot_name,
|
||
quantity: qty,
|
||
unit_price: comp.current_price,
|
||
estimate_price: comp.current_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
|
||
function showAutocompleteMulti(input) {
|
||
autocompleteInput = input;
|
||
autocompleteCategory = null;
|
||
autocompleteMode = 'multi';
|
||
autocompleteIndex = -1;
|
||
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 (!c.current_price) 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;
|
||
|
||
cart.push({
|
||
lot_name: comp.lot_name,
|
||
quantity: qty,
|
||
unit_price: comp.current_price,
|
||
estimate_price: comp.current_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)
|
||
function showAutocompleteSection(sectionId, input) {
|
||
autocompleteInput = input;
|
||
autocompleteCategory = sectionId; // Store section ID
|
||
autocompleteMode = 'section';
|
||
autocompleteIndex = -1;
|
||
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 (!c.current_price) 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;
|
||
|
||
cart.push({
|
||
lot_name: comp.lot_name,
|
||
quantity: qty,
|
||
unit_price: comp.current_price,
|
||
estimate_price: comp.current_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();
|
||
|
||
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 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,
|
||
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');
|
||
}
|
||
}
|
||
}
|
||
|
||
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 resp = await fetch('/api/export/csv', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({items: exportItems, name: configName})
|
||
});
|
||
|
||
const blob = await resp.blob();
|
||
const url = window.URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = (configName || 'config') + '.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})
|
||
});
|
||
|
||
const blob = await resp.blob();
|
||
const url = window.URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = (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" .}}
|