Files
admins 5f5c166527 fix(web): мобильная адаптация MedArchive (шапка, таблица, очередь)
- шапка: кросс-логотип MedPrice + разделитель скрыты на мобиле (nav Поиск/Админка
  больше не обрезается)
- таблица сравнения: вторичные колонки (Нерезидент, Прайс от) скрыты на мобиле,
  паддинги px-3 sm:px-5 — 3 колонки влезают без обрезки
- очередь верификации: карточка стекается flex-col на мобиле, кнопки flex-wrap —
  не наезжают на текст
Все правки под sm:-брейкпоинт, десктоп без изменений.

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

338 lines
19 KiB
HTML
Raw Permalink 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: { brand: { DEFAULT: '#ff4713', soft: '#ffede7', dark: '#e63e0f' },
tg: '#6b788e', flint: '#f5f7f9', ink: '#111827', ok: '#0f973d' },
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-brand 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.5">
<span class="text-2xl font-bold">Med<span class="text-brand">Archive</span></span>
<span class="h-6 w-px bg-[#d1d5db] hidden sm:block"></span>
<a href="https://medprice.secondbrain.tools" title="Перейти в MedPrice — агрегатор цен на медуслуги"
aria-label="Открыть MedPrice" class="hidden sm:inline text-2xl font-bold opacity-55 hover:opacity-100 transition-opacity">Med<span class="text-[#0d9488]">Price</span></a>
<span class="text-xs text-tg bg-flint border border-[#ebedf0] rounded-full px-2 py-0.5 ml-3 hidden sm:inline">прайсы клиник-партнёров</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-brand">сравните цены</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-brand focus:ring-2 focus:ring-brand/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">
<div class="flex items-center justify-between mt-6 gap-4 flex-wrap">
<h2 class="text-2xl font-bold">Дашборд обработки</h2>
<button onclick="exportXlsx()" class="bg-white border border-[#d1d5db] text-sm rounded-lg px-4 py-2 font-medium hover:bg-flint">📥 Экспорт в Excel</button>
</div>
<div id="stats" class="grid grid-cols-2 sm:grid-cols-4 gap-3 mt-4"></div>
<!-- Загрузка нового архива (ТЗ §4.1) -->
<div class="bg-white border border-[#ebedf0] rounded-2xl shadow-sm p-5 mt-6">
<div class="flex items-center justify-between gap-4 flex-wrap">
<div>
<h3 class="text-lg font-semibold">Загрузить архив прайсов</h3>
<p class="text-sm text-tg mt-1">ZIP с прайсами клиник (pdf, xlsx, xls, docx). Система распакует, распознает и догрузит позиции в базу.</p>
</div>
<div class="shrink-0">
<input id="zip" type="file" accept=".zip" class="hidden" onchange="uploadArchive(this)">
<button onclick="$('zip').click()" class="bg-brand text-white rounded-lg px-4 py-2 text-sm font-medium hover:bg-brand-dark">Выбрать ZIP</button>
</div>
</div>
<div id="ingest-result" class="mt-3 text-sm hidden"></div>
</div>
<div class="grid md:grid-cols-2 gap-4 mt-4">
<!-- Авто-загрузка из папки (watcher + крон) -->
<div class="bg-white border border-[#ebedf0] rounded-2xl shadow-sm p-5">
<h3 class="text-lg font-semibold">Авто-загрузка из папки</h3>
<p class="text-sm text-tg mt-1">Укажите папку — система в кроне сама заберёт и обработает свежие прайсы, без участия человека.</p>
<div class="flex gap-2 mt-3">
<input id="watchfolder" placeholder="например: /app/inbox" class="flex-1 bg-white border border-[#d1d5db] rounded-lg px-3 py-2 text-sm outline-none focus:border-brand">
<button onclick="saveFolder()" class="border border-[#e5e7eb] text-sm rounded-lg px-3 py-2 hover:bg-flint shrink-0">Сохранить</button>
</div>
<button onclick="scanNow()" class="bg-brand text-white text-sm rounded-lg px-4 py-2 mt-2 font-medium hover:bg-brand-dark">Проверить сейчас</button>
<div id="scan-result" class="text-sm mt-2"></div>
</div>
<!-- Изменения цен (детектор аномалий, ТЗ §4.4) -->
<div class="bg-white border border-[#ebedf0] rounded-2xl shadow-sm p-5">
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold">Изменения цен</h3>
<span id="pc-count" class="text-xs text-tg"></span>
</div>
<p class="text-sm text-tg mt-1">Услуги, где новый прайс изменил цену на ≥ 10% относительно прошлого.</p>
<div id="price-changes" class="mt-3 space-y-1 max-h-64 overflow-auto"></div>
</div>
</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-brand 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-brand-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-brand">' + money(spread) + '</b>' : ''}</div>
</div>
<table class="w-full text-sm">
<thead class="bg-flint text-tg text-left">
<tr><th class="px-3 sm:px-5 py-2.5 font-semibold">Клиника</th>
<th class="px-3 sm:px-5 py-2.5 font-semibold text-right whitespace-nowrap">Резидент</th>
<th class="px-3 sm:px-5 py-2.5 font-semibold text-right whitespace-nowrap">vs медиана</th>
<th class="px-3 sm:px-5 py-2.5 font-semibold text-right hidden sm:table-cell">Нерезидент</th>
<th class="px-3 sm:px-5 py-2.5 font-semibold text-right hidden sm:table-cell">Прайс от</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-brand-soft' : ''}">
<td class="px-3 sm:px-5 py-3">${r.partner_name}${cheapest ? ' <span class="text-brand text-xs font-semibold">· выгодно</span>' : ''}</td>
<td class="px-3 sm:px-5 py-3 text-right tabular-nums font-medium whitespace-nowrap">${money(r.price_resident_kzt)}</td>
<td class="px-3 sm:px-5 py-3 text-right tabular-nums text-xs">${badge}</td>
<td class="px-3 sm:px-5 py-3 text-right tabular-nums text-tg hidden sm:table-cell whitespace-nowrap">${money(r.price_nonresident_kzt)}</td>
<td class="px-3 sm:px-5 py-3 text-right text-tg text-xs hidden sm:table-cell whitespace-nowrap">${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-brand">${v}</div>
<div class="text-xs text-tg mt-1">${l}</div></div>`).join('');
loadQueue();
loadSettings();
loadPriceChanges();
}
function exportXlsx() { window.location = `${API}/export.xlsx`; }
async function loadSettings() {
const s = await fetch(`${API}/settings`).then(r => r.json());
$('watchfolder').value = s.watch_folder || '';
}
async function saveFolder() {
const folder = $('watchfolder').value.trim();
await fetch(`${API}/settings?watch_folder=${encodeURIComponent(folder)}`, { method: 'POST' });
$('scan-result').innerHTML = '<span class="text-tg">Папка сохранена.</span>';
}
async function scanNow() {
const box = $('scan-result');
box.innerHTML = '<span class="text-tg">⏳ Проверяю папку…</span>';
const res = await fetch(`${API}/scan`, { method: 'POST' });
const r = await res.json().catch(() => ({}));
if (!res.ok) { box.innerHTML = `<span class="text-[#dc2626]">${r.detail || 'ошибка'}</span>`; return; }
if (!r.new_files) { box.innerHTML = '<span class="text-tg">Новых прайсов нет.</span>'; return; }
box.innerHTML = `<span class="text-ok">✓ Обработано файлов: <b>${r.new_files}</b>, позиций: <b>${r.items}</b>, нормализовано <b>${r.auto_matched_pct}%</b>.</span>`;
loadAdmin();
}
async function loadPriceChanges() {
const d = await fetch(`${API}/price-changes?threshold=10&limit=40`).then(r => r.json());
$('pc-count').textContent = d.count ? `${d.count} услуг` : '';
if (!d.changes || !d.changes.length) {
$('price-changes').innerHTML = '<div class="text-tg text-sm">Изменений не найдено.</div>';
return;
}
$('price-changes').innerHTML = d.changes.map(c => {
const up = c.delta_pct > 0;
const badge = `<span class="${up ? 'text-[#dc2626]' : 'text-ok'} font-semibold whitespace-nowrap">${up ? '▲ +' : '▼ '}${c.delta_pct}%</span>`;
return `<div class="flex items-center justify-between gap-3 border-b border-[#f0f0f0] py-1.5">
<div class="min-w-0">
<div class="text-sm truncate">${c.service}</div>
<div class="text-xs text-tg">${c.partner_name} · ${money(c.old_price)}${money(c.new_price)}</div>
</div>${badge}</div>`;
}).join('');
}
async function uploadArchive(input) {
const f = input.files[0];
if (!f) return;
const box = $('ingest-result');
box.classList.remove('hidden');
box.innerHTML = `<span class="text-tg">⏳ Обрабатываю <b>${f.name}</b> — распаковка, распознавание и нормализация. Может занять до минуты…</span>`;
const fd = new FormData();
fd.append('file', f);
try {
const res = await fetch(`${API}/ingest`, { method: 'POST', body: fd });
const r = await res.json().catch(() => ({}));
if (!res.ok) {
box.innerHTML = `<span class="text-[#dc2626]">Ошибка: ${r.detail || res.status}</span>`;
} else {
const note = r.embeddings ? '' : ' <span class="text-tg">(нормализация по кодам и нечёткому совпадению — без эмбеддингов)</span>';
box.innerHTML = `<div class="bg-brand-soft border border-[#ffd9cc] rounded-lg p-3">
✓ Архив обработан: <b>${r.documents}</b> прайс(ов), <b>${r.items}</b> позиций,
автонормализация <b class="text-brand">${r.auto_matched_pct}%</b>${r.new_partners ? ', новых клиник ' + r.new_partners : ''}.${note}
<div class="text-tg mt-1">Новые позиции уже доступны в поиске и в очереди верификации.</div></div>`;
loadAdmin(); // обновить дашборд и очередь под новые данные
}
} catch (e) {
box.innerHTML = `<span class="text-[#dc2626]">Сбой загрузки: ${e}</span>`;
}
input.value = '';
}
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-brand">${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-brand text-white text-sm rounded-lg px-3 py-1.5 hover:bg-brand-dark">✓ Подтвердить</button>`
: '';
return `<div id="card-${it.item_id}" class="bg-white border border-[#ebedf0] rounded-xl p-4">
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-start gap-3">
<div class="min-w-0">
<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 flex-wrap sm:shrink-0">
${confirm}
<button onclick="togglePick(${it.item_id})" class="border border-[#e5e7eb] text-sm rounded-lg px-3 py-1.5 hover:bg-flint">Другое</button>
<button onclick="skipCard(${it.item_id})" class="text-tg text-sm rounded-lg px-3 py-1.5 hover:bg-flint">Пропустить</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-brand">
<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-brand-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>