Files
PriceForge/web/templates/pricelist_detail.html
Michael Chus 9bc01831c9 feat: add pricelist CSV export and improve description display
- Add CSV export functionality for pricelists with download button
- Export includes all pricelist items with proper UTF-8 encoding
- Support both warehouse and estimate pricelist sources
- Remove description column from admin pricing tables
- Show description as tooltip on row hover instead
- Improve table layout by removing redundant column

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-08 12:26:45 +03:00

358 lines
15 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>
<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 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>
<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 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 {
const resp = await fetch(`/api/pricelists/${pricelistId}/export-csv`);
if (!resp.ok) {
throw new Error('Ошибка экспорта');
}
const blob = await resp.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
// Get filename from Content-Disposition header or use default
const contentDisposition = resp.headers.get('Content-Disposition');
let filename = `pricelist_${pricelistId}.csv`;
if (contentDisposition) {
const filenameMatch = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/);
if (filenameMatch && filenameMatch[1]) {
filename = filenameMatch[1].replace(/['"]/g, '');
}
}
a.download = filename;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
showToast('CSV файл экспортирован', 'success');
} catch (e) {
showToast('Ошибка экспорта: ' + e.message, 'error');
}
}
document.addEventListener('DOMContentLoaded', function() {
loadPricelistInfo();
loadItems(1);
});
</script>
{{end}}
{{template "base" .}}