/* Главный экран админки по композиции референса: слева крупная визуализация сердца
с плавающими показателями, справа заголовок, сводка клиники, графики заявок и последние заявки.
Компоненты: 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 = '
Нужен 3D-рендер сердцаФайл assets/heart.webp: прозрачный фон, около 1400 px по высоте.
';
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 = 'все заявки' +
'записи на приёмполоса — обычный уровень';
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 = '| Дата / время | Пациент | Телефон | Откуда | Врач или услуга | Статус |
';
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 }));
}
},
};