Improve pricing modal performance and charting
This commit is contained in:
@@ -26,6 +26,8 @@ let compInlineSortField = 'popularity_score';
|
||||
let compInlineSortDir = 'desc';
|
||||
let compInlineSearchTimer = null;
|
||||
let b2bCompetitorsList = [];
|
||||
let previewAbortController = null;
|
||||
let previewRequestSeq = 0;
|
||||
|
||||
function getPricelistTabConfig(tab) {
|
||||
if (tab === 'estimate') return { source: 'estimate', title: 'Estimate' };
|
||||
@@ -127,7 +129,7 @@ async function loadTab(tab) {
|
||||
compInlinePage = 1; compInlineSearch = '';
|
||||
const searchEl = document.getElementById('comp-inline-search');
|
||||
if (searchEl) searchEl.value = '';
|
||||
await fetch('/api/admin/pricing/lots/sync-metadata', { method: 'POST' }).catch(() => {});
|
||||
fetch('/api/admin/pricing/lots/sync-metadata', { method: 'POST' }).catch(() => {});
|
||||
await loadCompInline();
|
||||
} else if (tab === 'warehouse') {
|
||||
await loadStockLotOptions();
|
||||
@@ -399,6 +401,128 @@ function escapeHtml(text) {
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function formatChartPrice(value) {
|
||||
if (!Number.isFinite(value)) return '—';
|
||||
const num = Number(value);
|
||||
if (Math.abs(num) >= 1000) {
|
||||
const compact = (num / 1000).toFixed(1).replace('.', ',').replace(/,0$/, '');
|
||||
return '$' + compact + 'k';
|
||||
}
|
||||
const rounded = Math.round(num * 10) / 10;
|
||||
const text = Number.isInteger(rounded) ? String(Math.trunc(rounded)) : String(rounded).replace('.', ',');
|
||||
return '$' + text;
|
||||
}
|
||||
|
||||
function resetPriceChart(message) {
|
||||
const chartEl = document.getElementById('modal-price-chart');
|
||||
if (!chartEl) return;
|
||||
chartEl.innerHTML = `<div class="h-44 flex items-center justify-center text-sm text-gray-400">${escapeHtml(message || 'Нет данных')}</div>`;
|
||||
}
|
||||
|
||||
function renderPriceChart(history, currentPrice) {
|
||||
const chartEl = document.getElementById('modal-price-chart');
|
||||
if (!chartEl) return;
|
||||
|
||||
const rows = Array.isArray(history) ? history.filter(item => item && item.date && Number.isFinite(Number(item.price))) : [];
|
||||
const current = Number(currentPrice);
|
||||
const hasCurrent = Number.isFinite(current) && current > 0;
|
||||
|
||||
if (rows.length === 0 && !hasCurrent) {
|
||||
resetPriceChart('Нет данных для графика');
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = rows.map(item => ({
|
||||
date: String(item.date),
|
||||
ts: new Date(item.date).getTime(),
|
||||
price: Number(item.price)
|
||||
})).filter(item => Number.isFinite(item.ts) && Number.isFinite(item.price));
|
||||
|
||||
if (parsed.length === 0 && !hasCurrent) {
|
||||
resetPriceChart('Нет данных для графика');
|
||||
return;
|
||||
}
|
||||
|
||||
const width = 560;
|
||||
const height = 176;
|
||||
const padLeft = 46;
|
||||
const padRight = 12;
|
||||
const padTop = 12;
|
||||
const padBottom = 28;
|
||||
const plotWidth = width - padLeft - padRight;
|
||||
const plotHeight = height - padTop - padBottom;
|
||||
|
||||
let minTs = parsed.length > 0 ? parsed[0].ts : Date.now();
|
||||
let maxTs = parsed.length > 0 ? parsed[parsed.length - 1].ts : minTs;
|
||||
if (minTs === maxTs) {
|
||||
minTs -= 12 * 60 * 60 * 1000;
|
||||
maxTs += 12 * 60 * 60 * 1000;
|
||||
}
|
||||
|
||||
const priceValues = parsed.map(item => item.price);
|
||||
if (hasCurrent) priceValues.push(current);
|
||||
let minPrice = Math.min(...priceValues);
|
||||
let maxPrice = Math.max(...priceValues);
|
||||
if (minPrice === maxPrice) {
|
||||
minPrice = Math.max(0, minPrice - 1);
|
||||
maxPrice = maxPrice + 1;
|
||||
}
|
||||
const pricePad = Math.max((maxPrice - minPrice) * 0.12, 1);
|
||||
minPrice = Math.max(0, minPrice - pricePad);
|
||||
maxPrice = maxPrice + pricePad;
|
||||
|
||||
const scaleX = ts => padLeft + ((ts - minTs) / (maxTs - minTs)) * plotWidth;
|
||||
const scaleY = price => padTop + plotHeight - ((price - minPrice) / (maxPrice - minPrice)) * plotHeight;
|
||||
|
||||
const quotePoints = parsed.map(item => `${scaleX(item.ts).toFixed(2)},${scaleY(item.price).toFixed(2)}`).join(' ');
|
||||
const pointMarkers = parsed.map(item => {
|
||||
const x = scaleX(item.ts).toFixed(2);
|
||||
const y = scaleY(item.price).toFixed(2);
|
||||
const title = `${new Date(item.ts).toLocaleDateString('ru-RU')}: ${formatChartPrice(item.price)}`;
|
||||
return `
|
||||
<circle cx="${x}" cy="${y}" r="3.5" fill="#f97316" stroke="#ffffff" stroke-width="1.5">
|
||||
<title>${escapeHtml(title)}</title>
|
||||
</circle>
|
||||
`;
|
||||
}).join('');
|
||||
const currentY = hasCurrent ? scaleY(current).toFixed(2) : null;
|
||||
const startDate = new Date(minTs).toLocaleDateString('ru-RU');
|
||||
const endDate = new Date(maxTs).toLocaleDateString('ru-RU');
|
||||
const midPrice = (minPrice + maxPrice) / 2;
|
||||
const tickCount = parsed.length > 1 ? 4 : 2;
|
||||
const xTicks = [];
|
||||
for (let i = 0; i < tickCount; i++) {
|
||||
const ratio = tickCount === 1 ? 0 : i / (tickCount - 1);
|
||||
const ts = minTs + (maxTs - minTs) * ratio;
|
||||
const x = scaleX(ts).toFixed(2);
|
||||
const label = new Date(ts).toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit', year: i === 0 || i === tickCount - 1 ? '2-digit' : undefined });
|
||||
xTicks.push(`
|
||||
<line x1="${x}" y1="${padTop}" x2="${x}" y2="${padTop + plotHeight}" stroke="#e2e8f0" stroke-width="1" stroke-dasharray="3 4"></line>
|
||||
<text x="${x}" y="${height - 8}" text-anchor="${i === 0 ? 'start' : i === tickCount - 1 ? 'end' : 'middle'}" fill="#64748b" font-size="11">${escapeHtml(label)}</text>
|
||||
`);
|
||||
}
|
||||
|
||||
chartEl.innerHTML = `
|
||||
<svg viewBox="0 0 ${width} ${height}" class="w-full h-44 overflow-visible" role="img" aria-label="История цены компонента">
|
||||
<rect x="${padLeft}" y="${padTop}" width="${plotWidth}" height="${plotHeight}" rx="8" fill="#ffffff"></rect>
|
||||
<line x1="${padLeft}" y1="${padTop}" x2="${padLeft}" y2="${padTop + plotHeight}" stroke="#cbd5e1" stroke-width="1"></line>
|
||||
<line x1="${padLeft}" y1="${padTop + plotHeight}" x2="${padLeft + plotWidth}" y2="${padTop + plotHeight}" stroke="#cbd5e1" stroke-width="1"></line>
|
||||
<line x1="${padLeft}" y1="${scaleY(maxPrice).toFixed(2)}" x2="${padLeft + plotWidth}" y2="${scaleY(maxPrice).toFixed(2)}" stroke="#e2e8f0" stroke-width="1"></line>
|
||||
<line x1="${padLeft}" y1="${scaleY(midPrice).toFixed(2)}" x2="${padLeft + plotWidth}" y2="${scaleY(midPrice).toFixed(2)}" stroke="#e2e8f0" stroke-width="1" stroke-dasharray="3 4"></line>
|
||||
<line x1="${padLeft}" y1="${scaleY(minPrice).toFixed(2)}" x2="${padLeft + plotWidth}" y2="${scaleY(minPrice).toFixed(2)}" stroke="#e2e8f0" stroke-width="1"></line>
|
||||
${xTicks.join('')}
|
||||
${hasCurrent ? `<line x1="${padLeft}" y1="${currentY}" x2="${padLeft + plotWidth}" y2="${currentY}" stroke="#64748b" stroke-width="2" stroke-dasharray="6 4"></line>` : ''}
|
||||
${quotePoints ? `<polyline fill="none" stroke="#f97316" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" points="${quotePoints}"></polyline>` : ''}
|
||||
${pointMarkers}
|
||||
<text x="8" y="${scaleY(maxPrice).toFixed(2)}" fill="#64748b" font-size="11">${escapeHtml(formatChartPrice(maxPrice))}</text>
|
||||
<text x="8" y="${scaleY(midPrice).toFixed(2)}" fill="#94a3b8" font-size="11">${escapeHtml(formatChartPrice(midPrice))}</text>
|
||||
<text x="8" y="${scaleY(minPrice).toFixed(2)}" fill="#64748b" font-size="11">${escapeHtml(formatChartPrice(minPrice))}</text>
|
||||
<text x="${padLeft}" y="${padTop - 2}" fill="#94a3b8" font-size="10">${escapeHtml(startDate)}</text>
|
||||
<text x="${padLeft + plotWidth}" y="${padTop - 2}" text-anchor="end" fill="#94a3b8" font-size="10">${escapeHtml(endDate)}</text>
|
||||
</svg>
|
||||
`;
|
||||
}
|
||||
|
||||
// Modal functions
|
||||
function openModal(idx) {
|
||||
const c = componentsCache[idx];
|
||||
@@ -447,6 +571,7 @@ function openModal(idx) {
|
||||
document.getElementById('modal-current-price').textContent = '...';
|
||||
document.getElementById('modal-new-price').textContent = '...';
|
||||
document.getElementById('modal-quote-count').textContent = '...';
|
||||
resetPriceChart('Загрузка...');
|
||||
|
||||
document.getElementById('price-modal').classList.remove('hidden');
|
||||
document.getElementById('price-modal').classList.add('flex');
|
||||
@@ -470,6 +595,12 @@ function onMethodChange() {
|
||||
}
|
||||
|
||||
async function fetchPreview() {
|
||||
if (previewAbortController) {
|
||||
previewAbortController.abort();
|
||||
}
|
||||
previewAbortController = new AbortController();
|
||||
const requestSeq = ++previewRequestSeq;
|
||||
|
||||
const lotName = document.getElementById('modal-lot-name').value;
|
||||
const method = document.getElementById('modal-method').value;
|
||||
const periodDays = parseInt(document.getElementById('modal-period').value) || 0;
|
||||
@@ -491,6 +622,7 @@ async function fetchPreview() {
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
signal: previewAbortController.signal,
|
||||
body: JSON.stringify({
|
||||
lot_name: lotName,
|
||||
method: method,
|
||||
@@ -505,6 +637,7 @@ async function fetchPreview() {
|
||||
|
||||
if (resp.ok) {
|
||||
const data = await resp.json();
|
||||
if (requestSeq !== previewRequestSeq) return;
|
||||
|
||||
// Update last price with date
|
||||
if (data.last_price) {
|
||||
@@ -545,17 +678,24 @@ async function fetchPreview() {
|
||||
quoteCountText = data.quote_count || 0;
|
||||
}
|
||||
document.getElementById('modal-quote-count').textContent = quoteCountText;
|
||||
renderPriceChart(data.estimate_history || [], data.current_price);
|
||||
}
|
||||
} catch(e) {
|
||||
if (e.name === 'AbortError') return;
|
||||
console.error('Preview fetch error:', e);
|
||||
document.getElementById('modal-last-price').textContent = '—';
|
||||
document.getElementById('modal-median-all').textContent = '—';
|
||||
document.getElementById('modal-current-price').textContent = '—';
|
||||
document.getElementById('modal-new-price').textContent = '—';
|
||||
resetPriceChart('Не удалось загрузить график');
|
||||
}
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
if (previewAbortController) {
|
||||
previewAbortController.abort();
|
||||
previewAbortController = null;
|
||||
}
|
||||
document.getElementById('price-modal').classList.add('hidden');
|
||||
document.getElementById('price-modal').classList.remove('flex');
|
||||
}
|
||||
@@ -640,7 +780,11 @@ async function savePrice() {
|
||||
|
||||
if (resp.ok) {
|
||||
closeModal();
|
||||
loadData();
|
||||
if (currentTab === 'estimate') {
|
||||
await loadCompInline();
|
||||
} else {
|
||||
await loadData();
|
||||
}
|
||||
} else {
|
||||
const data = await resp.json();
|
||||
alert('Ошибка: ' + (data.error || 'Неизвестная ошибка'));
|
||||
|
||||
Reference in New Issue
Block a user