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