Improve pricing modal performance and charting

This commit is contained in:
Mikhail Chusavitin
2026-03-17 12:37:58 +03:00
parent df5be91353
commit df14da2265
5 changed files with 451 additions and 111 deletions

View File

@@ -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 || 'Неизвестная ошибка'));