dana-medical-site/internal/api/adminui/dashboard.js
2026-10-14 13:33:13 +00:00

290 lines
17 KiB
JavaScript
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.

/* Главный экран админки по композиции референса: слева крупная визуализация сердца
с плавающими показателями, справа заголовок, сводка клиники, графики заявок и последние заявки.
Компоненты: ClinicSummary, HeartVisualization, VitalBadge, TrendsCard, LeadsTable. */
const LEAD_STATUS = {
new: ['Новая', 'off'],
in_progress: ['В работе', 'sky'],
done: ['Обработана', ''],
spam: ['Спам', 'grey'],
};
const plural = (n, one, few, many) => {
const a = Math.abs(n) % 100, b = a % 10;
return a > 10 && a < 20 ? many : b === 1 ? one : b >= 2 && b <= 4 ? few : many;
};
const svgEl = (tag, attrs = {}) => {
const n = document.createElementNS('http://www.w3.org/2000/svg', tag);
for (const [k, v] of Object.entries(attrs)) n.setAttribute(k, v);
return n;
};
/* ---------- ClinicSummary: заголовок и три показателя без карточек ---------- */
function ClinicSummary(d) {
const box = el('div', { className: 'summary reveal' });
const h = el('h1', { className: 'summary__title' });
h.append('Панель', el('br'), 'клиники');
const stats = el('div', { className: 'summary__stats' });
for (const [value, label, tab] of [
[d.doctors, plural(d.doctors, 'врач', 'врача', 'врачей'), 'doctors'],
[d.services, plural(d.services, 'услуга', 'услуги', 'услуг'), 'services'],
[d.branches, plural(d.branches, 'филиал', 'филиала', 'филиалов'), 'settings'],
]) {
const b = el('button', { type: 'button', className: 'stat' });
b.append(el('b', { textContent: value }), el('span', { textContent: label }));
b.onclick = () => go(tab);
stats.append(b);
}
box.append(h, stats);
return box;
}
/* ---------- VitalBadge: маленькая белая карточка рядом с маркером ---------- */
function VitalBadge({ label, value, unit, tab, x, y }) {
const b = el('button', { type: 'button', className: 'vital reveal' });
b.style.left = x; b.style.top = y;
b.append(el('span', { className: 'vital__label', textContent: label }), el('span', { className: 'vital__value', textContent: value }));
if (unit) b.append(el('span', { className: 'vital__unit', textContent: unit }));
b.onclick = () => go(tab);
return b;
}
/* ---------- HeartVisualization ---------- */
// Реалистичный рендер сердца — файл assets/heart.webp (прозрачный фон). Если его нет,
// показываем честную пометку, а не рисованную заглушку.
const HEART_SRC = 'assets/heart.webp';
let heartAvailable = null;
async function heartExists() {
if (heartAvailable === null) {
heartAvailable = await fetch(HEART_SRC, { method: 'HEAD', credentials: 'same-origin' }).then((r) => r.ok).catch(() => false);
}
return heartAvailable;
}
async function HeartVisualization(d) {
const box = el('div', { className: 'heart' });
const hasHeart = await heartExists();
if (hasHeart) {
box.append(el('img', { className: 'heart__img reveal', src: HEART_SRC, alt: '', decoding: 'async' }));
} else {
const miss = el('div', { className: 'heart__missing reveal' });
miss.innerHTML = '<div><b>Нужен 3D-рендер сердца</b>Файл <code>assets/heart.webp</code>: прозрачный фон, около 1400&nbsp;px по высоте.</div>';
box.append(miss);
}
// маркеры на поверхности и карточки рядом — расположены асимметрично, как в референсе
const spots = [
{ mx: 36, my: 27, badge: { label: 'Новые заявки', value: d.new_leads, unit: d.leads_today ? `сегодня ${d.leads_today}` : 'ждут ответа', tab: 'leads', x: '52%', y: '6%' } },
{ mx: 50, my: 49, badge: { label: 'Отзывы на проверке', value: d.pending_reviews, unit: 'ждут публикации', tab: 'reviews', x: '64%', y: '42%' } },
{ mx: 41, my: 74, badge: { label: 'Акции на сайте', value: d.active_promos, unit: d.active_promos ? 'действуют сейчас' : 'нет активных', tab: 'promos', x: '2%', y: '80%' } },
];
spots.forEach((s, i) => {
const b = VitalBadge(s.badge);
b.style.animationDelay = (0.5 + i * 0.15) + 's';
// маркеры отмечают точки на поверхности сердца — без рендера им не на чем стоять
if (!hasHeart) { box.append(b); return; }
const m = el('span', { className: 'marker reveal' });
m.style.left = s.mx + '%'; m.style.top = s.my + '%';
m.style.animationDelay = (0.4 + i * 0.15) + 's';
m.setAttribute('aria-hidden', 'true');
box.append(m, b);
});
return box;
}
/* ---------- TrendsCard: два лёгких графика с полосой обычного уровня ---------- */
// «Обычный уровень» — середина значений за период (от 25-го до 75-го перцентиля).
// Это ориентир для глаза, а не норма.
function quantile(sorted, q) {
const i = (sorted.length - 1) * q, lo = Math.floor(i), hi = Math.ceil(i);
return sorted[lo] + (sorted[hi] - sorted[lo]) * (i - lo);
}
function smoothPath(pts) {
let d = `M ${pts[0][0]} ${pts[0][1]}`;
for (let i = 0; i < pts.length - 1; i++) {
const p0 = pts[i - 1] || pts[i], p1 = pts[i], p2 = pts[i + 1], p3 = pts[i + 2] || p2;
const c1 = [p1[0] + (p2[0] - p0[0]) / 6, p1[1] + (p2[1] - p0[1]) / 6];
const c2 = [p2[0] - (p3[0] - p1[0]) / 6, p2[1] - (p3[1] - p1[1]) / 6];
d += ` C ${c1[0]} ${c1[1]}, ${c2[0]} ${c2[1]}, ${p2[0]} ${p2[1]}`;
}
return d;
}
let chartSeq = 0;
function LineChart({ values, dates, band, framed, showX, tipLabel, title }) {
const id = 'c' + (++chartSeq);
const W = 1000, H = framed ? 178 : 150, L = 62, R = 16, T = framed ? 26 : 16, B = showX ? 38 : 14;
const wrap = el('div', { className: 'chart' });
const s = svgEl('svg', { viewBox: `0 0 ${W} ${H}`, role: 'img', 'aria-label': `${title}: ${values.join(', ')}` });
// шкала по фактическому размаху с запасом, а не от нуля: так видно, как день отличается от обычного
const vmax = Math.max(...values), vmin = Math.min(...values);
const pad = Math.max((vmax - vmin) * 0.18, 1);
const max = vmax + pad, min = Math.max(0, vmin - pad);
const span = (max - min) || 1;
const y = (v) => T + (H - T - B) * (1 - (v - min) / span);
const x = (i) => L + (W - L - R) * (values.length === 1 ? 0.5 : i / (values.length - 1));
const sorted = [...values].sort((a, b) => a - b);
const lo = quantile(sorted, 0.25), hi = quantile(sorted, 0.75);
const yTop = y(hi), yBot = Math.max(y(lo), yTop + 10);
const defs = svgEl('defs');
const bandGrad = svgEl('linearGradient', { id: id + 'b', x1: '0', x2: '1' });
band.stops.forEach(([o, c, a]) => bandGrad.append(svgEl('stop', { offset: o, 'stop-color': c, 'stop-opacity': a })));
const peakGrad = svgEl('linearGradient', { id: id + 'p', x1: '0', x2: '0', y1: String(y(vmax)), y2: String(yTop), gradientUnits: 'userSpaceOnUse' });
peakGrad.append(svgEl('stop', { offset: '0', 'stop-color': '#f2566a', 'stop-opacity': '0.95' }), svgEl('stop', { offset: '1', 'stop-color': '#f2566a', 'stop-opacity': '0' }));
const lineGrad = svgEl('linearGradient', { id: id + 'l', x1: '0', x2: '1' });
[['0', 0.25], ['0.1', 1], ['0.9', 1], ['1', 0.25]].forEach(([o, a]) => lineGrad.append(svgEl('stop', { offset: o, 'stop-color': '#8b8f8d', 'stop-opacity': a })));
const clip = svgEl('clipPath', { id: id + 'k' });
clip.append(svgEl('rect', { x: 0, y: 0, width: W, height: yTop }));
defs.append(bandGrad, peakGrad, lineGrad, clip);
s.append(defs);
if (framed) s.append(svgEl('rect', { class: 'chart__frame', x: L - 18, y: 6, width: W - L - R + 30, height: H - B - 6 + 4, rx: 18 }));
s.append(svgEl('rect', { x: L, y: yTop, width: W - L - R, height: yBot - yTop, fill: `url(#${id}b)`, rx: 2 }));
// подписи — границы полосы, как в референсе (280 / 52)
const fmt = (v) => (Math.round(v * 10) / 10).toString().replace('.', ',');
s.append(Object.assign(svgEl('text', { class: 'chart__axis', x: L - 26, y: yTop + 4, 'text-anchor': 'end' }), { textContent: fmt(hi) }));
s.append(Object.assign(svgEl('text', { class: 'chart__axis', x: L - 26, y: yBot + 4, 'text-anchor': 'end' }), { textContent: fmt(lo) }));
const pts = values.map((v, i) => [x(i), y(v)]);
const d = smoothPath(pts);
// розово-красная заливка пиков: всё, что выше полосы, закрашено до её верхней границы
s.append(svgEl('path', { d: `${d} L ${x(values.length - 1)} ${yTop} L ${x(0)} ${yTop} Z`, fill: `url(#${id}p)`, 'clip-path': `url(#${id}k)` }));
const line = svgEl('path', { class: 'chart__line draw', d, stroke: `url(#${id}l)` });
s.append(line);
if (showX) {
const df = new Intl.DateTimeFormat('ru-RU', { day: 'numeric', month: 'short' });
dates.forEach((dt, i) => {
if (i % 2) return;
s.append(Object.assign(svgEl('text', { class: 'chart__axis', x: x(i), y: H - 10, 'text-anchor': 'middle' }), { textContent: df.format(new Date(dt + 'T12:00:00')).replace('.', '') }));
});
}
// подсказка с измерением при наведении
const guide = svgEl('line', { class: 'chart__guide', y1: T - 6, y2: H - B, visibility: 'hidden' });
const dot = svgEl('circle', { class: 'chart__dot', r: 4.5, visibility: 'hidden' });
s.append(guide, dot);
const tip = el('div', { className: 'tooltip', hidden: true });
const step = (W - L - R) / Math.max(values.length - 1, 1);
values.forEach((v, i) => {
const hit = svgEl('rect', { class: 'chart__hit', x: x(i) - step / 2, y: 0, width: step, height: H, tabindex: '0' });
const show = () => {
guide.setAttribute('x1', x(i)); guide.setAttribute('x2', x(i)); guide.setAttribute('visibility', 'visible');
dot.setAttribute('cx', x(i)); dot.setAttribute('cy', y(v)); dot.setAttribute('visibility', 'visible');
const date = new Intl.DateTimeFormat('ru-RU', { day: 'numeric', month: 'long', weekday: 'short' }).format(new Date(dates[i] + 'T12:00:00'));
tip.innerHTML = '';
tip.append(el('b', { textContent: `${v} ${plural(v, tipLabel[0], tipLabel[1], tipLabel[2])}` }), date);
tip.style.left = (x(i) / W * 100) + '%'; tip.style.top = (y(v) / H * 100) + '%';
tip.hidden = false;
};
const hide = () => { tip.hidden = true; guide.setAttribute('visibility', 'hidden'); dot.setAttribute('visibility', 'hidden'); };
hit.addEventListener('mouseenter', show); hit.addEventListener('focus', show);
hit.addEventListener('mouseleave', hide); hit.addEventListener('blur', hide);
s.append(hit);
});
wrap.append(s, tip);
// длина линии для анимации отрисовки
requestAnimationFrame(() => { try { line.style.setProperty('--len', Math.ceil(line.getTotalLength())); } catch { /* старый браузер */ } });
return wrap;
}
// Пример для пустой базы: до запуска сайта заявок почти нет, а пустой график ничего не показывает.
function demoDays(days) {
const leads = [5, 12, 7, 16, 19, 9, 5, 11, 17, 22, 12, 6, 13, 20];
const appts = [2, 7, 4, 10, 12, 5, 3, 6, 11, 14, 7, 3, 8, 12];
return days.map((d, i) => ({ date: d.date, leads: leads[i % leads.length], appointments: appts[i % appts.length] }));
}
function TrendsCard(d) {
const card = el('section', { className: 'card trends reveal', ariaLabel: 'Заявки за 14 дней' });
const real = d.days.reduce((a, x) => a + x.leads, 0);
const demo = real < 5;
const days = demo ? demoDays(d.days) : d.days;
const head = el('div', { className: 'trends__head' });
head.append(el('h2', { textContent: 'Заявки за 14 дней' }));
if (demo) head.append(el('span', { className: 'trends__demo', textContent: `Пример данных: заявок пока ${real}` }));
const legend = el('div', { className: 'trends__legend' });
legend.innerHTML = '<span><i style="background:linear-gradient(90deg,#f4e9b2,#f5c9ac)"></i>все заявки</span>' +
'<span><i style="background:#bfe8d6"></i>записи на приём</span><span title="От 25-го до 75-го перцентиля за период">полоса — обычный уровень</span>';
head.append(legend);
card.append(head);
const dates = days.map((x) => x.date);
card.append(LineChart({
values: days.map((x) => x.leads), dates, framed: true, showX: true, tipLabel: ['заявка', 'заявки', 'заявок'], title: 'Все заявки по дням',
band: { stops: [['0', '#f4e9b2', 0], ['0.1', '#f2e2a0', 1], ['0.4', '#f5c29f', 1], ['0.55', '#f0a8ab', 0.95], ['0.85', '#f2e2a0', 1], ['1', '#f4e9b2', 0]] },
}));
card.append(LineChart({
values: days.map((x) => x.appointments), dates, framed: false, showX: false, tipLabel: ['запись', 'записи', 'записей'], title: 'Записи на приём по дням',
band: { stops: [['0', '#bfe8d6', 0], ['0.12', '#b3e6cc', 1], ['0.45', '#93c9ad', 1], ['0.7', '#b3e6cc', 1], ['1', '#bfe8d6', 0]] },
}));
return card;
}
/* ---------- LeadsTable: последние заявки ---------- */
async function LeadsTable() {
const card = el('section', { className: 'card leads-card reveal' });
const head = el('div', { className: 'trends__head', style: 'margin-bottom:6px' });
head.append(el('h2', { textContent: 'Последние заявки' }));
const all = el('button', { type: 'button', className: 'card-link', textContent: 'Все заявки →' });
all.onclick = () => go('leads');
head.append(all);
card.append(head);
const r = await api('/admin/leads?limit=4');
const list = r.data || [];
const table = el('table', { className: 'leads-table' });
table.innerHTML = '<thead><tr><th>Дата / время</th><th>Пациент</th><th>Телефон</th><th>Откуда</th><th>Врач или услуга</th><th>Статус</th></tr></thead>';
const tb = el('tbody');
if (!list.length) {
const tr = el('tr'); tr.append(el('td', { colSpan: 6, className: 'soft', textContent: 'Заявок пока нет — они появятся здесь сразу после отправки формы на сайте.' })); tb.append(tr);
}
const df = new Intl.DateTimeFormat('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric' });
const tf = new Intl.DateTimeFormat('ru-RU', { hour: '2-digit', minute: '2-digit' });
for (const l of list) {
const tr = el('tr');
const when = el('td'); when.append(df.format(new Date(l.created_at)), el('small', { textContent: tf.format(new Date(l.created_at)) }));
const [label, cls] = LEAD_STATUS[l.status] || [l.status, 'grey'];
const st = el('td'); st.append(el('span', { className: 'pill ' + cls, textContent: label }));
tr.append(when, el('td', { className: 'strong', textContent: l.name || '—' }), el('td', { textContent: l.phone }),
el('td', { className: 'soft', textContent: l.form_title || '—' }), el('td', { className: 'soft', textContent: l.doctor_name || l.service_name || '—' }), st);
tr.tabIndex = 0;
const open = () => { go('leads'); Leads.open(l); };
tr.onclick = open; tr.onkeydown = (e) => { if (e.key === 'Enter') open(); };
tb.append(tr);
}
table.append(tb);
card.append(table);
return card;
}
/* ---------- сборка экрана ---------- */
const Dashboard = {
// Сводка нужна и шапке (календарь, колокольчик, бейдж заявок) — на любой странице админки.
async summary() {
const r = await api('/admin/dashboard');
const d = r.data;
d.days = d.days || []; d.upcoming = d.upcoming || []; d.todo = d.todo || [];
Topbar.calendar(d.upcoming);
Topbar.todo(d.todo);
const badge = $('#ld-badge');
if (badge) { badge.textContent = d.new_leads; badge.hidden = !d.new_leads; }
return d;
},
async load() {
const root = $('#tab-home');
try {
const d = await Dashboard.summary();
const grid = el('div', { className: 'dash' });
const left = el('div', { className: 'dash__heart' });
const right = el('div', { className: 'dash__right' });
left.append(await HeartVisualization(d));
right.append(ClinicSummary(d), TrendsCard(d), await LeadsTable());
grid.append(left, right);
root.textContent = '';
root.append(grid);
} catch (e) {
root.textContent = '';
root.append(el('div', { className: 'card', textContent: 'Не удалось загрузить сводку: ' + e.message }));
}
},
};