- 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>
358 lines
15 KiB
HTML
358 lines
15 KiB
HTML
{{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, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, ''');
|
||
}
|
||
|
||
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">←</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">→</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" .}}
|