Files
PriceForge/web/templates/base.html
Mikhail Chusavitin f48615e8a9 Modularize Go files, extract JS to static, implement competitor pricelists
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>
2026-03-13 07:44:10 +03:00

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}}