Go refactoring: - Split handlers/pricing.go (2446→291 lines) into 5 focused files - Split services/stock_import.go (1334→~400 lines) into stock_mappings.go + stock_parse.go - Split services/sync/service.go (1290→~250 lines) into 3 files JS extraction: - Move all inline <script> blocks to web/static/js/ (6 files) - Templates reduced: admin_pricing 2873→521, lot 1531→304, vendor_mappings 1063→169, etc. Competitor pricelists (migrations 033-039): - qt_competitors + partnumber_log_competitors tables - Excel import with column mapping, dedup, bulk insert - p/n→lot resolution via weighted_median, discount applied - Unmapped p/ns written to qt_vendor_partnumber_seen - Quote counts (unique/total) shown on /admin/competitors - price_method="weighted_median", price_period_days=0 stored explicitly Fix price_method/price_period_days for warehouse items: - warehouse: weighted_avg, period=0 - competitor: weighted_median, period=0 - Removes misleading DB defaults (was: median/90) Update bible: architecture.md, pricelist.md, history.md Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
172 lines
8.6 KiB
HTML
172 lines
8.6 KiB
HTML
{{define "base"}}
|
|
<!DOCTYPE html>
|
|
<html lang="ru">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>{{template "title" .}}</title>
|
|
<script src="https://cdn.tailwindcss.com"></script>
|
|
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
|
<style>
|
|
.htmx-request { opacity: 0.5; }
|
|
.line-clamp-2 { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
|
</style>
|
|
</head>
|
|
<body class="bg-gray-100 min-h-screen">
|
|
<nav class="bg-white shadow-sm sticky top-0 z-40">
|
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
|
<div class="flex justify-between h-14">
|
|
<div class="flex items-center space-x-8">
|
|
<a href="/" class="text-xl font-bold text-orange-600">PriceForge</a>
|
|
<div class="hidden md:flex space-x-2">
|
|
<a href="/lot" class="{{if eq .ActivePage `lot`}}bg-orange-50 text-orange-700{{else}}text-gray-600 hover:text-gray-900 hover:bg-gray-50{{end}} px-3 py-2 text-sm rounded-md transition">LOT</a>
|
|
<a href="/vendor-mappings" class="{{if eq .ActivePage `vendor_mappings`}}bg-orange-50 text-orange-700{{else}}text-gray-600 hover:text-gray-900 hover:bg-gray-50{{end}} px-3 py-2 text-sm rounded-md transition">Vendor mappings</a>
|
|
<a href="/admin/competitors" class="{{if eq .ActivePage `competitors`}}bg-orange-50 text-orange-700{{else}}text-gray-600 hover:text-gray-900 hover:bg-gray-50{{end}} px-3 py-2 text-sm rounded-md transition">Конкуренты</a>
|
|
<a href="/admin/pricing" class="{{if eq .ActivePage `admin`}}bg-orange-50 text-orange-700{{else}}text-gray-600 hover:text-gray-900 hover:bg-gray-50{{end}} px-3 py-2 text-sm rounded-md transition">Администратор цен</a>
|
|
<a href="/setup" class="{{if eq .ActivePage `setup`}}bg-orange-50 text-orange-700{{else}}text-gray-600 hover:text-gray-900 hover:bg-gray-50{{end}} px-3 py-2 text-sm rounded-md transition">Настройки</a>
|
|
</div>
|
|
</div>
|
|
<div class="flex items-center space-x-4">
|
|
<div id="task-indicator" class="hidden items-center gap-1 text-sm"></div>
|
|
<span id="db-connection-status" class="inline-flex items-center gap-1 text-xs text-gray-600">
|
|
<span class="inline-block w-2 h-2 rounded-full bg-gray-400"></span>
|
|
DB: checking
|
|
</span>
|
|
<span id="db-user" class="text-sm text-gray-600"></span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</nav>
|
|
|
|
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
|
{{template "content" .}}
|
|
</main>
|
|
|
|
<div id="toast" class="fixed bottom-4 right-4 z-50"></div>
|
|
|
|
<script>
|
|
function showToast(msg, type) {
|
|
const colors = { success: 'bg-green-500', error: 'bg-red-500', info: 'bg-orange-500' };
|
|
const el = document.getElementById('toast');
|
|
el.replaceChildren();
|
|
const toast = document.createElement('div');
|
|
toast.className = (colors[type] || colors.info) + ' text-white px-4 py-2 rounded shadow';
|
|
toast.textContent = String(msg || '');
|
|
el.appendChild(toast);
|
|
setTimeout(() => el.replaceChildren(), 3000);
|
|
}
|
|
|
|
function renderDBStatus(connected, errorText) {
|
|
const el = document.getElementById('db-connection-status');
|
|
if (!el) return;
|
|
if (connected) {
|
|
el.innerHTML = '<span class="inline-block w-2 h-2 rounded-full bg-green-500"></span>DB: online';
|
|
el.title = '';
|
|
return;
|
|
}
|
|
el.innerHTML = '<span class="inline-block w-2 h-2 rounded-full bg-red-500"></span>DB: offline';
|
|
el.title = errorText || 'Database not connected';
|
|
}
|
|
|
|
const knownTasks = new Map(); // taskID -> {status, notified}
|
|
|
|
async function refreshSystemStatus() {
|
|
try {
|
|
// Poll DB status
|
|
const dbResp = await fetch('/api/db-status');
|
|
const dbData = await dbResp.json();
|
|
renderDBStatus(dbData.connected === true, dbData.error || '');
|
|
|
|
const userEl = document.getElementById('db-user');
|
|
if (dbData.connected && dbData.db_user) {
|
|
userEl.textContent = '@' + String(dbData.db_user);
|
|
} else if (userEl) {
|
|
userEl.textContent = '';
|
|
}
|
|
|
|
// Poll tasks
|
|
const tasksResp = await fetch('/api/tasks');
|
|
const tasksData = await tasksResp.json();
|
|
updateTaskIndicator(tasksData.tasks || []);
|
|
} catch (e) {
|
|
renderDBStatus(false, 'Status request failed');
|
|
}
|
|
}
|
|
|
|
function updateTaskIndicator(tasks) {
|
|
const indicator = document.getElementById('task-indicator');
|
|
if (!indicator) return;
|
|
|
|
const runningTasks = tasks.filter(t => t.status === 'running');
|
|
|
|
if (runningTasks.length > 0) {
|
|
indicator.classList.remove('hidden');
|
|
indicator.classList.add('flex');
|
|
indicator.innerHTML = `
|
|
<svg class="animate-spin h-4 w-4 text-orange-600" fill="none" viewBox="0 0 24 24">
|
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
|
</svg>
|
|
<span class="text-xs">${runningTasks.length}</span>
|
|
`;
|
|
indicator.title = runningTasks.map(t => t.message).join(', ');
|
|
} else {
|
|
indicator.classList.add('hidden');
|
|
indicator.classList.remove('flex');
|
|
indicator.innerHTML = '';
|
|
}
|
|
|
|
// Check for newly completed/errored tasks
|
|
tasks.forEach(task => {
|
|
const known = knownTasks.get(task.id);
|
|
|
|
if (!known) {
|
|
// New task - check if it's recently completed (within last 10 seconds)
|
|
const isRecentlyCompleted = task.status !== 'running' && task.done_at;
|
|
if (isRecentlyCompleted) {
|
|
const doneAt = new Date(task.done_at);
|
|
const now = new Date();
|
|
const ageSeconds = (now - doneAt) / 1000;
|
|
|
|
// Show notification for tasks completed within the last 10 seconds
|
|
if (ageSeconds < 10) {
|
|
if (task.status === 'completed') {
|
|
showToast(task.message || 'Задача завершена', 'success');
|
|
} else if (task.status === 'error') {
|
|
showToast(task.error || 'Ошибка выполнения задачи', 'error');
|
|
}
|
|
knownTasks.set(task.id, {status: task.status, notified: true});
|
|
} else {
|
|
knownTasks.set(task.id, {status: task.status, notified: false});
|
|
}
|
|
} else {
|
|
knownTasks.set(task.id, {status: task.status, notified: false});
|
|
}
|
|
} else if (known.status === 'running' && task.status !== 'running' && !known.notified) {
|
|
// Task transitioned from running to completed/error
|
|
if (task.status === 'completed') {
|
|
showToast(task.message || 'Задача завершена', 'success');
|
|
} else if (task.status === 'error') {
|
|
showToast(task.error || 'Ошибка выполнения задачи', 'error');
|
|
}
|
|
knownTasks.set(task.id, {status: task.status, notified: true});
|
|
}
|
|
});
|
|
|
|
// Cleanup old tasks from knownTasks
|
|
for (const [taskID, info] of knownTasks.entries()) {
|
|
if (!tasks.find(t => t.id === taskID)) {
|
|
knownTasks.delete(taskID);
|
|
}
|
|
}
|
|
}
|
|
|
|
document.addEventListener('DOMContentLoaded', function () {
|
|
refreshSystemStatus();
|
|
setInterval(refreshSystemStatus, 5000); // 5 second polling
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>
|
|
{{end}}
|