Files
PriceForge/web/templates/pricelist_detail.html
2026-02-25 19:06:28 +03:00

462 lines
20 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{{define "title"}}Прайслист - PriceForge{{end}}
{{define "content"}}
<div class="space-y-6">
<div class="flex items-center justify-between">
<div class="flex items-center space-x-4">
<a href="/pricelists" 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 id="page-title" class="text-2xl font-bold text-gray-900">Загрузка...</h1>
</div>
<div class="flex items-center gap-2">
<button onclick="openPriceChangesReport()" class="px-4 py-2 border border-gray-300 text-gray-700 rounded-md hover:bg-gray-50">
Отчет по изменениям
</button>
<button onclick="exportToCSV()" class="px-4 py-2 bg-orange-600 text-white rounded-md hover:bg-orange-700 flex items-center space-x-2">
<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="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
</svg>
<span>Экспорт в CSV</span>
</button>
</div>
</div>
<div id="pricelist-info" class="bg-white rounded-lg shadow p-6">
<div id="pl-notification" class="hidden mb-4 p-3 bg-yellow-50 border border-yellow-200 rounded-lg text-yellow-800"></div>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
<div>
<p class="text-sm text-gray-500">Версия</p>
<p id="pl-version" class="font-mono">-</p>
</div>
<div>
<p class="text-sm text-gray-500">Дата создания</p>
<p id="pl-date">-</p>
</div>
<div>
<p class="text-sm text-gray-500">Автор</p>
<p id="pl-author">-</p>
</div>
<div>
<p class="text-sm text-gray-500">Позиций</p>
<p id="pl-items">-</p>
</div>
<div>
<p class="text-sm text-gray-500">Использований</p>
<p id="pl-usage">-</p>
</div>
<div>
<p class="text-sm text-gray-500">Статус</p>
<p id="pl-status">-</p>
</div>
<div>
<p class="text-sm text-gray-500">Истекает</p>
<p id="pl-expires">-</p>
</div>
</div>
</div>
<div class="bg-white rounded-lg shadow overflow-hidden">
<div class="p-4 border-b">
<input type="text" id="search-input" placeholder="Поиск по артикулу..."
class="w-full md:w-64 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-orange-500">
</div>
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Артикул</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Категория</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Описание</th>
<th id="th-qty" class="hidden px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">Доступно</th>
<th id="th-partnumbers" class="hidden px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Partnumbers</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">Цена, $</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Настройки</th>
</tr>
</thead>
<tbody id="items-body" class="bg-white divide-y divide-gray-200">
<tr>
<td colspan="7" class="px-6 py-4 text-center text-gray-500">Загрузка...</td>
</tr>
</tbody>
</table>
<div id="items-pagination" class="p-4 border-t flex justify-between items-center">
<span id="items-info" class="text-sm text-gray-500"></span>
<div id="items-pages" class="space-x-2"></div>
</div>
</div>
</div>
<div id="price-changes-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
<div class="bg-white rounded-lg p-6 max-w-4xl w-full mx-4 max-h-[85vh] overflow-hidden flex flex-col">
<div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-bold">Отчет по изменениям цен</h2>
<button type="button" onclick="closePriceChangesModal()" class="text-gray-500 hover:text-gray-700">Закрыть</button>
</div>
<div id="price-changes-summary" class="text-sm text-gray-600 mb-4"></div>
<div id="price-changes-content" class="overflow-auto space-y-4"></div>
</div>
</div>
<script>
const pricelistId = window.location.pathname.split('/').pop();
let currentPage = 1;
let searchQuery = '';
let searchTimeout = null;
let currentSource = '';
async function loadPricelistInfo() {
try {
const resp = await fetch(`/api/pricelists/${pricelistId}`);
if (!resp.ok) throw new Error('Pricelist not found');
const pl = await resp.json();
currentSource = pl.source || '';
toggleWarehouseColumns();
document.getElementById('page-title').textContent = `Прайслист ${pl.version}`;
document.getElementById('pl-version').textContent = pl.version;
document.getElementById('pl-date').textContent = new Date(pl.created_at).toLocaleDateString('ru-RU');
document.getElementById('pl-author').textContent = pl.created_by || '-';
document.getElementById('pl-items').textContent = pl.item_count;
document.getElementById('pl-usage').textContent = pl.usage_count;
// Show notification if present and pricelist is active
const notificationEl = document.getElementById('pl-notification');
if (pl.notification && pl.is_active) {
notificationEl.textContent = pl.notification;
notificationEl.classList.remove('hidden');
} else {
notificationEl.classList.add('hidden');
}
const statusClass = pl.is_active ? 'text-green-600' : 'text-gray-600';
document.getElementById('pl-status').innerHTML = `<span class="${statusClass}">${pl.is_active ? 'Активен' : 'Неактивен'}</span>`;
if (pl.expires_at) {
document.getElementById('pl-expires').textContent = new Date(pl.expires_at).toLocaleDateString('ru-RU');
} else {
document.getElementById('pl-expires').textContent = '-';
}
} catch (e) {
document.getElementById('page-title').textContent = 'Ошибка';
showToast('Прайслист не найден', 'error');
}
}
async function loadItems(page = 1) {
currentPage = page;
try {
let url = `/api/pricelists/${pricelistId}/items?page=${page}&per_page=50`;
if (searchQuery) {
url += `&search=${encodeURIComponent(searchQuery)}`;
}
const resp = await fetch(url);
const data = await resp.json();
currentSource = data.source || currentSource;
toggleWarehouseColumns();
renderItems(data.items || []);
renderItemsPagination(data.total, data.page, data.per_page);
} catch (e) {
document.getElementById('items-body').innerHTML = `
<tr>
<td colspan="${itemsColspan()}" class="px-6 py-4 text-center text-red-500">
Ошибка загрузки: ${e.message}
</td>
</tr>
`;
}
}
function isWarehouseSource() {
return (currentSource || '').toLowerCase() === 'warehouse';
}
function itemsColspan() {
return isWarehouseSource() ? 7 : 5;
}
function toggleWarehouseColumns() {
const visible = isWarehouseSource();
document.getElementById('th-qty').classList.toggle('hidden', !visible);
document.getElementById('th-partnumbers').classList.toggle('hidden', !visible);
}
function formatQty(qty) {
if (typeof qty !== 'number') return '—';
if (Number.isInteger(qty)) return qty.toString();
return qty.toLocaleString('ru-RU', { minimumFractionDigits: 0, maximumFractionDigits: 3 });
}
function escapeHtml(text) {
if (text === null || text === undefined) return '';
return String(text)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
function closePriceChangesModal() {
document.getElementById('price-changes-modal').classList.add('hidden');
document.getElementById('price-changes-modal').classList.remove('flex');
}
function formatReportPrice(value) {
if (value === null || value === undefined || Number.isNaN(Number(value))) return '-';
return Number(value).toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
function renderPriceChangesTable(items, showDiff = true) {
if (!Array.isArray(items) || items.length === 0) return '<div class="text-sm text-gray-500">Нет данных</div>';
const rows = items.map(item => {
const t = item.change_type || '';
const rowClass = t === 'increased' ? 'bg-red-50' : (t === 'decreased' ? 'bg-green-50' : '');
const diffValue = Number(item.diff || 0);
const diffClass = diffValue > 0 ? 'text-red-700' : (diffValue < 0 ? 'text-green-700' : 'text-gray-700');
const diffText = (showDiff && item.diff != null) ? `${diffValue > 0 ? '+' : ''}${formatReportPrice(diffValue)}` : '-';
return `
<tr class="${rowClass}">
<td class="px-3 py-2 font-mono text-xs">${escapeHtml(item.lot_name || '')}</td>
<td class="px-3 py-2 text-right">${formatReportPrice(item.old_price)}</td>
<td class="px-3 py-2 text-right">${item.new_price == null ? '-' : formatReportPrice(item.new_price)}</td>
<td class="px-3 py-2 text-right ${diffClass}">${diffText}</td>
</tr>
`;
}).join('');
return `
<div class="border rounded-lg overflow-hidden">
<table class="min-w-full text-sm">
<thead class="bg-gray-50">
<tr>
<th class="px-3 py-2 text-left">Позиция</th>
<th class="px-3 py-2 text-right">Было</th>
<th class="px-3 py-2 text-right">Стало</th>
<th class="px-3 py-2 text-right">Изм.</th>
</tr>
</thead>
<tbody>${rows}</tbody>
</table>
</div>
`;
}
async function openPriceChangesReport() {
try {
const summary = document.getElementById('price-changes-summary');
const content = document.getElementById('price-changes-content');
summary.textContent = 'Загрузка отчета...';
content.innerHTML = '';
document.getElementById('price-changes-modal').classList.remove('hidden');
document.getElementById('price-changes-modal').classList.add('flex');
const resp = await fetch(`/api/pricelists/${pricelistId}/price-changes`);
if (!resp.ok) throw new Error('Не удалось загрузить отчет');
const changes = await resp.json();
const changed = Array.isArray(changes.changed) ? changes.changed : [];
const missing = Array.isArray(changes.missing) ? changes.missing : [];
const prevVersion = changes.previous_pricelist_version || null;
summary.innerHTML = prevVersion
? `Сравнение с прайслистом <span class="font-mono">${escapeHtml(prevVersion)}</span>. Изменилось: <b>${changed.length}</b>, пропало (нет котировок): <b>${missing.length}</b>.`
: 'Предыдущий прайслист для сравнения не найден.';
content.innerHTML = `
<div>
<h3 class="font-semibold mb-2">Изменившиеся цены</h3>
${renderPriceChangesTable(changed, true)}
</div>
<div>
<h3 class="font-semibold mb-2">Пропали котировки (цена была раньше)</h3>
${renderPriceChangesTable(missing, false)}
</div>
`;
} catch (e) {
showToast('Ошибка: ' + e.message, 'error');
closePriceChangesModal();
}
}
function formatPriceSettings(item) {
// Format price settings to match admin pricing interface style
let settings = [];
const hasManualPrice = item.manual_price && item.manual_price > 0;
const hasMeta = item.meta_prices && item.meta_prices.trim() !== '';
const method = (item.price_method || '').toLowerCase();
// Method indicator
if (hasManualPrice) {
settings.push('<span class="text-orange-600 font-medium">РУЧН</span>');
} else if (method === 'average') {
settings.push('Сред');
} else if (method === 'weighted_median') {
settings.push('Взвеш. мед');
} else {
settings.push('Мед');
}
// Period (only if not manual price)
if (!hasManualPrice) {
const period = item.price_period_days !== undefined && item.price_period_days !== null ? item.price_period_days : 90;
if (period === 7) settings.push('1н');
else if (period === 30) settings.push('1м');
else if (period === 90) settings.push('3м');
else if (period === 365) settings.push('1г');
else if (period === 0) settings.push('все');
else settings.push(period + 'д');
}
// Coefficient
if (item.price_coefficient && item.price_coefficient !== 0) {
settings.push((item.price_coefficient > 0 ? '+' : '') + item.price_coefficient + '%');
}
// Meta article indicator
if (hasMeta) {
settings.push('<span class="text-purple-600 font-medium">МЕТА</span>');
}
return settings.join(' | ') || '-';
}
function renderItems(items) {
if (items.length === 0) {
document.getElementById('items-body').innerHTML = `
<tr>
<td colspan="${itemsColspan()}" class="px-6 py-4 text-center text-gray-500">
${searchQuery ? 'Ничего не найдено' : 'Позиции не найдены'}
</td>
</tr>
`;
return;
}
const showWarehouse = isWarehouseSource();
const html = items.map(item => {
const price = item.price.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
const description = item.lot_description || '-';
const truncatedDesc = description.length > 60 ? description.substring(0, 60) + '...' : description;
const qty = formatQty(item.available_qty);
const partnumbers = Array.isArray(item.partnumbers) && item.partnumbers.length > 0 ? item.partnumbers.join(', ') : '—';
return `
<tr class="hover:bg-gray-50">
<td class="px-6 py-3 whitespace-nowrap">
<span class="font-mono text-sm">${item.lot_name}</span>
</td>
<td class="px-6 py-3 whitespace-nowrap">
<span class="px-2 py-1 text-xs bg-gray-100 rounded">${item.category || '-'}</span>
</td>
<td class="px-6 py-3 text-sm text-gray-500" title="${description}">${truncatedDesc}</td>
${showWarehouse ? `<td class="px-6 py-3 whitespace-nowrap text-right font-mono">${qty}</td>` : ''}
${showWarehouse ? `<td class="px-6 py-3 text-sm text-gray-600" title="${escapeHtml(partnumbers)}">${escapeHtml(partnumbers)}</td>` : ''}
<td class="px-6 py-3 whitespace-nowrap text-right font-mono">${price}</td>
<td class="px-6 py-3 whitespace-nowrap text-sm"><span class="text-xs bg-gray-100 px-2 py-1 rounded">${formatPriceSettings(item)}</span></td>
</tr>
`;
}).join('');
document.getElementById('items-body').innerHTML = html;
}
function renderItemsPagination(total, page, perPage) {
const totalPages = Math.ceil(total / perPage);
const start = (page - 1) * perPage + 1;
const end = Math.min(page * perPage, total);
document.getElementById('items-info').textContent = `${start}-${end} из ${total}`;
if (totalPages <= 1) {
document.getElementById('items-pages').innerHTML = '';
return;
}
let html = '';
// Previous button
if (page > 1) {
html += `<button onclick="loadItems(${page - 1})" class="px-2 py-1 text-sm text-gray-600 hover:text-gray-900">&larr;</button>`;
}
// Page numbers (show max 5 pages)
const startPage = Math.max(1, page - 2);
const endPage = Math.min(totalPages, startPage + 4);
for (let i = startPage; i <= endPage; i++) {
const activeClass = i === page ? 'bg-orange-600 text-white' : 'bg-white text-gray-700 hover:bg-gray-50';
html += `<button onclick="loadItems(${i})" class="px-3 py-1 text-sm rounded border ${activeClass}">${i}</button>`;
}
// Next button
if (page < totalPages) {
html += `<button onclick="loadItems(${page + 1})" class="px-2 py-1 text-sm text-gray-600 hover:text-gray-900">&rarr;</button>`;
}
document.getElementById('items-pages').innerHTML = html;
}
document.getElementById('search-input').addEventListener('input', function(e) {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
searchQuery = e.target.value.trim();
loadItems(1);
}, 300);
});
async function exportToCSV() {
try {
showToast('Экспорт в CSV...', 'info');
const resp = await fetch(`/api/pricelists/${pricelistId}/export-csv`);
if (!resp.ok) {
throw new Error('Ошибка экспорта');
}
// Get filename from Content-Disposition header
const contentDisposition = resp.headers.get('Content-Disposition');
let filename = `pricelist_${pricelistId}.csv`;
if (contentDisposition) {
const matches = contentDisposition.match(/filename="?([^";\n]+)"?/);
if (matches && matches[1]) {
filename = matches[1];
}
}
const blob = await resp.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.style.display = 'none';
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
// Cleanup
setTimeout(() => {
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
}, 100);
showToast('CSV файл экспортирован', 'success');
} catch (e) {
showToast('Ошибка экспорта: ' + e.message, 'error');
}
}
document.addEventListener('DOMContentLoaded', function() {
loadPricelistInfo();
loadItems(1);
});
</script>
{{end}}
{{template "base" .}}