985faadf36
- etl/pipeline.py — extract→normalize→validate→SQLite, дедуп партнёров, версии цен, отчёт качества
- api/main.py — 7 эндпоинтов на SQLite; ядро /services/{id}/partners (сравнение)
- web/index.html — одностраничный фронт (палитра Nomad): поиск, сравнение, дашборд, unmatched
- загрузчик справочника: уникальный service_id
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
138 lines
7.1 KiB
HTML
138 lines
7.1 KiB
HTML
<!doctype html>
|
||
<html lang="ru">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>MedArchive — сравнение цен на медуслуги</title>
|
||
<link rel="preconnect" href="https://fonts.bunny.net">
|
||
<link href="https://fonts.bunny.net/css?family=geologica:300,400,500,600,700&display=swap" rel="stylesheet">
|
||
<script src="https://cdn.tailwindcss.com"></script>
|
||
<script>
|
||
tailwind.config = { theme: { extend: {
|
||
colors: { nomad: { DEFAULT: '#ff4713', soft: '#ffede7', dark: '#e63e0f' },
|
||
tg: '#6b788e', lg: '#f5f7f9', ink: '#111827' },
|
||
fontFamily: { sans: ['Geologica', 'ui-sans-serif', 'system-ui', 'sans-serif'] } } } };
|
||
</script>
|
||
<style>body{font-family:'Geologica',system-ui,sans-serif}</style>
|
||
</head>
|
||
<body class="bg-[#f3f4f6] text-ink min-h-screen">
|
||
<div class="h-1 bg-nomad w-full"></div>
|
||
|
||
<header class="max-w-5xl mx-auto px-5 pt-8 pb-2 flex items-center justify-between">
|
||
<div class="flex items-center gap-2">
|
||
<span class="text-2xl font-bold">Med<span class="text-nomad">Archive</span></span>
|
||
<span class="text-xs text-tg bg-lg border border-[#ebedf0] rounded-full px-2 py-0.5">прайсы клиник-партнёров</span>
|
||
</div>
|
||
<nav class="text-sm text-tg flex gap-4">
|
||
<button onclick="show('search')" class="hover:text-ink">Поиск</button>
|
||
<button onclick="show('admin')" class="hover:text-ink">Админка</button>
|
||
</nav>
|
||
</header>
|
||
|
||
<main class="max-w-5xl mx-auto px-5 pb-16">
|
||
|
||
<!-- Поиск + сравнение -->
|
||
<section id="view-search">
|
||
<h1 class="text-3xl sm:text-4xl font-bold mt-6 leading-tight">Найдите услугу — <span class="text-nomad">сравните цены</span> в клиниках</h1>
|
||
<p class="text-tg mt-2">Единый каталог услуг и цен из архива прайсов клиник-партнёров.</p>
|
||
|
||
<div class="mt-5 flex gap-2">
|
||
<input id="q" type="text" placeholder="например: УЗИ брюшной полости, приём кардиолога, общий анализ крови"
|
||
class="flex-1 bg-white border border-[#d1d5db] rounded-lg px-4 py-3 outline-none focus:border-nomad focus:ring-2 focus:ring-nomad/40"
|
||
oninput="debouncedSearch()" autocomplete="off">
|
||
</div>
|
||
|
||
<div id="suggest" class="mt-2 bg-white border border-[#ebedf0] rounded-xl divide-y divide-[#f0f0f0] hidden shadow-sm"></div>
|
||
<div id="compare" class="mt-6"></div>
|
||
</section>
|
||
|
||
<!-- Админка -->
|
||
<section id="view-admin" class="hidden">
|
||
<h2 class="text-2xl font-bold mt-6">Дашборд обработки</h2>
|
||
<div id="stats" class="grid grid-cols-2 sm:grid-cols-4 gap-3 mt-4"></div>
|
||
<h3 class="text-lg font-semibold mt-8">Очередь ручной разметки (unmatched)</h3>
|
||
<div id="unmatched" class="mt-3 bg-white border border-[#ebedf0] rounded-xl overflow-hidden"></div>
|
||
</section>
|
||
|
||
</main>
|
||
|
||
<script>
|
||
const API = new URLSearchParams(location.search).get('api') || '/api';
|
||
const $ = (id) => document.getElementById(id);
|
||
const money = (v) => v == null ? '—' : new Intl.NumberFormat('ru-RU').format(v) + ' ₸';
|
||
|
||
function show(view) {
|
||
$('view-search').classList.toggle('hidden', view !== 'search');
|
||
$('view-admin').classList.toggle('hidden', view !== 'admin');
|
||
if (view === 'admin') loadAdmin();
|
||
}
|
||
|
||
let timer;
|
||
function debouncedSearch() { clearTimeout(timer); timer = setTimeout(search, 250); }
|
||
|
||
async function search() {
|
||
const q = $('q').value.trim();
|
||
const box = $('suggest');
|
||
if (q.length < 2) { box.classList.add('hidden'); return; }
|
||
const data = await fetch(`${API}/search?q=${encodeURIComponent(q)}&limit=8`).then(r => r.json());
|
||
const items = (data.services || []);
|
||
box.innerHTML = items.length
|
||
? items.map(s => `<button class="block w-full text-left px-4 py-2.5 hover:bg-nomad-soft"
|
||
onclick="compare('${s.service_id}', ${JSON.stringify(s.name_ru).replace(/"/g, '"')})">
|
||
<span class="font-medium">${s.name_ru}</span>
|
||
<span class="text-xs text-tg ml-2">${s.specialty || ''}</span></button>`).join('')
|
||
: `<div class="px-4 py-3 text-tg text-sm">Ничего не найдено</div>`;
|
||
box.classList.remove('hidden');
|
||
}
|
||
|
||
async function compare(serviceId, name) {
|
||
$('suggest').classList.add('hidden');
|
||
const rows = await fetch(`${API}/services/${encodeURIComponent(serviceId)}/partners`).then(r => r.json());
|
||
const best = rows.reduce((m, r) => (r.price_resident_kzt != null && (m == null || r.price_resident_kzt < m)) ? r.price_resident_kzt : m, null);
|
||
$('compare').innerHTML = `
|
||
<div class="bg-white border border-[#ebedf0] rounded-2xl shadow-sm overflow-hidden">
|
||
<div class="px-5 py-4 border-b border-[#ebedf0]">
|
||
<div class="text-lg font-semibold">${name}</div>
|
||
<div class="text-sm text-tg">${rows.length} клиник(и) оказывают услугу</div>
|
||
</div>
|
||
<table class="w-full text-sm">
|
||
<thead class="bg-lg text-tg text-left">
|
||
<tr><th class="px-5 py-2.5 font-semibold">Клиника</th>
|
||
<th class="px-5 py-2.5 font-semibold text-right">Резидент</th>
|
||
<th class="px-5 py-2.5 font-semibold text-right">Нерезидент</th>
|
||
<th class="px-5 py-2.5 font-semibold text-right">Прайс от</th></tr>
|
||
</thead>
|
||
<tbody class="divide-y divide-[#f0f0f0]">
|
||
${rows.map(r => {
|
||
const cheapest = r.price_resident_kzt != null && r.price_resident_kzt === best;
|
||
return `<tr class="${cheapest ? 'bg-nomad-soft' : ''}">
|
||
<td class="px-5 py-3">${r.partner_name}${cheapest ? ' <span class="text-nomad text-xs font-semibold">· выгодно</span>' : ''}</td>
|
||
<td class="px-5 py-3 text-right tabular-nums font-medium">${money(r.price_resident_kzt)}</td>
|
||
<td class="px-5 py-3 text-right tabular-nums text-tg">${money(r.price_nonresident_kzt)}</td>
|
||
<td class="px-5 py-3 text-right text-tg text-xs">${r.effective_date || '—'}</td></tr>`;
|
||
}).join('')}
|
||
</tbody>
|
||
</table>
|
||
</div>`;
|
||
}
|
||
|
||
async function loadAdmin() {
|
||
const s = await fetch(`${API}/stats`).then(r => r.json());
|
||
const cards = [
|
||
['Документов', s.documents_total], ['Позиций', s.items_total],
|
||
['Автонормализация', s.auto_matched_pct + '%'], ['В очереди', s.unmatched_total]];
|
||
$('stats').innerHTML = cards.map(([l, v]) => `
|
||
<div class="bg-white border border-[#ebedf0] rounded-xl p-4">
|
||
<div class="text-2xl font-bold text-nomad">${v}</div>
|
||
<div class="text-xs text-tg mt-1">${l}</div></div>`).join('');
|
||
const u = await fetch(`${API}/unmatched?limit=25`).then(r => r.json());
|
||
$('unmatched').innerHTML = `<table class="w-full text-sm"><tbody class="divide-y divide-[#f0f0f0]">
|
||
${u.map(x => `<tr><td class="px-5 py-2.5">${x.service_name_raw}</td>
|
||
<td class="px-5 py-2.5 text-tg text-xs">${x.partner_name}</td>
|
||
<td class="px-5 py-2.5 text-right tabular-nums text-tg">${Object.values(x.prices)[0] != null ? money(Object.values(x.prices)[0]) : '—'}</td></tr>`).join('')}
|
||
</tbody></table>`;
|
||
}
|
||
</script>
|
||
</body>
|
||
</html>
|