Unified export filename format across both ExportCSV and ExportConfigCSV: - Format: YYYY-MM-DD (project_name) config_name BOM.csv - Use PriceUpdatedAt if available, otherwise CreatedAt - Extract project name from ProjectUUID for ExportCSV via projectService - Pass project_uuid from frontend to backend in export request - Add projectUUID and projectName state variables to track project context This ensures consistent naming whether exporting from form or project view, and uses most recent price update timestamp in filename. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2164 lines
86 KiB
HTML
2164 lines
86 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 projectUUID = '';
|
||
let projectName = '';
|
||
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();
|
||
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 || '';
|
||
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;
|
||
}
|
||
|
||
// 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();
|
||
|
||
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');
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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 resp = await fetch('/api/export/csv', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({items: exportItems, 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');
|
||
}
|
||
}
|
||
|
||
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" .}}
|