290 lines
17 KiB
JavaScript
290 lines
17 KiB
JavaScript
/* Главный экран админки по композиции референса: слева крупная визуализация сердца
|
||
с плавающими показателями, справа заголовок, сводка клиники, графики заявок и последние заявки.
|
||
Компоненты: 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 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 }));
|
||
}
|
||
},
|
||
};
|