Files
medtech-hackathon/web/index.html
T
admins 89928511ba feat(верификация): рабочая очередь с пагинацией + /skip
- автодогрузка порций + кнопка «Загрузить ещё»; разбор сверху по уверенности
- POST /skip фиксирует пропуск (таблица skipped_item), очередь его исключает

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 10:58:10 +05:00

223 lines
12 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.
<!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>
<div class="flex items-center justify-between mt-8">
<h3 class="text-lg font-semibold">Очередь верификации</h3>
<div id="vprogress" class="text-sm text-tg"></div>
</div>
<p class="text-sm text-tg mt-1">Система предлагает кандидата из справочника — подтвердите, выберите другой или пропустите. Подтверждение обучает систему.</p>
<div id="queue" class="mt-3 space-y-2"></div>
<button id="loadmore" onclick="loadQueue()" class="mt-3 text-sm text-nomad hover:underline hidden">Загрузить ещё →</button>
</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, '&quot;')})">
<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 vals = rows.map(r => r.price_resident_kzt).filter(v => v != null).sort((a, b) => a - b);
const best = vals.length ? vals[0] : null;
const worst = vals.length ? vals[vals.length - 1] : null;
const median = vals.length ? (vals.length % 2 ? vals[(vals.length - 1) / 2] : (vals[vals.length / 2 - 1] + vals[vals.length / 2]) / 2) : null;
const dev = (v) => (median && v != null) ? Math.round((v - median) / median * 100) : null;
const spread = (best != null && worst != null) ? worst - best : 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} клиник(и) · медиана ${money(median)}${spread ? ' · разброс цен до <b class="text-nomad">' + money(spread) + '</b>' : ''}</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">vs медиана</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;
const d = dev(r.price_resident_kzt);
const badge = d == null ? '—' : (d > 0
? `<span class="text-[#dc2626] font-medium">+${d}%</span>`
: `<span class="text-[#0f973d] font-medium">${d}%</span>`);
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-xs">${badge}</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('');
loadQueue();
}
async function loadQueue() {
const items = await fetch(`${API}/unmatched?limit=25`).then(r => r.json());
$('queue').innerHTML = items.map(renderCard).join('') || '<div class="text-tg p-4">Очередь пуста 🎉</div>';
$('loadmore').classList.toggle('hidden', items.length === 0);
loadCounts();
}
function maybeRefill() {
// Рабочая очередь: когда необработанных карточек мало — подгружаем следующую порцию.
if ($('queue').querySelectorAll('[id^="card-"]').length < 6) loadQueue();
}
async function loadCounts() {
const c = await fetch(`${API}/unmatched/count`).then(r => r.json());
$('vprogress').innerHTML = `осталось <b>${c.remaining}</b> · обучено синонимов <b class="text-nomad">${c.learned_synonyms}</b>`;
}
function renderCard(it) {
const price = Object.values(it.prices)[0];
const sug = it.suggested;
const sugHtml = sug
? `<div class="text-sm mt-1">предложение: <b>${sug.name_ru}</b> <span class="text-tg">${sug.specialty || ''} · ${(sug.score || 0).toFixed(2)}</span></div>`
: `<div class="text-sm text-tg mt-1">кандидата нет</div>`;
const confirm = sug
? `<button onclick="doMatch(${it.item_id}, '${sug.service_id}', this)" class="bg-nomad text-white text-sm rounded-lg px-3 py-1.5 hover:bg-nomad-dark">✓ Подтвердить</button>`
: '';
return `<div id="card-${it.item_id}" class="bg-white border border-[#ebedf0] rounded-xl p-4">
<div class="flex justify-between items-start gap-3">
<div>
<div>извлечено: <b>${it.service_name_raw}</b></div>
<div class="text-sm text-tg">${it.partner_name}${price != null ? ' · ' + money(price) : ''}</div>
${sugHtml}
</div>
<div class="flex gap-2 shrink-0">
${confirm}
<button onclick="togglePick(${it.item_id})" class="border border-[#e5e7eb] text-sm rounded-lg px-3 py-1.5 hover:bg-lg">Другое</button>
<button onclick="skipCard(${it.item_id})" class="text-tg text-sm rounded-lg px-3 py-1.5 hover:bg-lg">Пропустить</button>
</div>
</div>
<div id="pick-${it.item_id}" class="hidden mt-3">
<input oninput="searchDict(${it.item_id}, this.value)" placeholder="найти услугу в справочнике…"
class="w-full bg-white border border-[#d1d5db] rounded-lg px-3 py-2 text-sm outline-none focus:border-nomad">
<div id="dict-${it.item_id}" class="mt-1 bg-white border border-[#ebedf0] rounded-lg divide-y divide-[#f0f0f0] max-h-44 overflow-auto"></div>
</div>
</div>`;
}
async function doMatch(itemId, serviceId, btn) {
if (btn) btn.disabled = true;
await fetch(`${API}/match?item_id=${itemId}&service_id=${encodeURIComponent(serviceId)}`, { method: 'POST' });
const card = $(`card-${itemId}`); if (card) card.remove();
loadCounts(); maybeRefill();
}
function togglePick(id) { $(`pick-${id}`).classList.toggle('hidden'); }
async function skipCard(id) {
await fetch(`${API}/skip?item_id=${id}`, { method: 'POST' });
const c = $(`card-${id}`); if (c) c.remove();
loadCounts(); maybeRefill();
}
let dictTimer;
function searchDict(itemId, q) {
clearTimeout(dictTimer);
dictTimer = setTimeout(async () => {
if (q.trim().length < 2) return;
const items = await fetch(`${API}/services?q=${encodeURIComponent(q)}&limit=8`).then(r => r.json());
$(`dict-${itemId}`).innerHTML = items.map(s =>
`<button class="block w-full text-left px-3 py-2 text-sm hover:bg-nomad-soft" onclick="doMatch(${itemId}, '${s.service_id}', null)">${s.name_ru} <span class="text-tg text-xs">${s.specialty || ''}</span></button>`).join('');
}, 250);
}
</script>
</body>
</html>