/* «Дашборд по неделям»: только просмотр накопленной истории периодов. Сами периоды появляются автоматически при загрузке выгрузки 1С на вкладке «Новый отчёт» (если в файле есть строка «Период ДД.ММ.ГГГГ - ДД.ММ.ГГГГ»). Здесь — таблица, фильтр по датам и правка бюджета. */ // Conditional-format rules read from «Показатели Дана (11).xlsx», «Дана неделя». // Excel ranges identify the corresponding metric. In the live dashboard they // extend across that metric's full history, including weeks hidden by the filter. const WeeklyExcelFormatting = { rules: { callcenter: { unique: { range: 'C8:P8', colors: ['#E78273', '#FFD666', '#57BB8A'] }, revenue: { range: 'C12:AA12', colors: ['#E67C73', '#FFD666', '#57BB8A'] }, }, 'сайт': { leads: { range: 'C17:U17', colors: ['#E78273', '#FFD666', '#57BB8A'] }, revenue: { range: 'C22:Z22', colors: ['#E98972', '#FFD666', '#57BB8A'] }, }, '2гис': { leads: { range: 'C35 / D35:Z35', separatePeriod: '30.03-05.04', colors: ['#E78273', '#FFD666', '#57BB8A'] }, revenue: { range: 'C40:AA40', colors: ['#E67C73', '#FFD666', '#57BB8A'] }, }, 'яндекскарточка': { leads: { range: 'C43:U44', colors: ['#E78273', '#FFD666', '#57BB8A'] }, revenue: { range: 'C49:P49', colors: ['#E78273', '#FFD666', '#57BB8A'] }, }, 'продокторов': { leads: { range: 'C62:P62', colors: ['#E78273', '#FFD666', '#57BB8A'] }, revenue: { range: 'C67:P67', colors: ['#E78273', '#FFD666', '#57BB8A'] }, }, 'напоправку': { revenue: { range: 'C76:P76', colors: ['#E78273', '#FFD666', '#E78273'] } }, 'сберздоровье': { revenue: { range: 'C85:P85', colors: ['#E78273', '#FFD666', '#57BB8A'] } }, zoon: { revenue: { range: 'C94:P94', colors: ['#E78273', '#FFD666', '#57BB8A'] } }, 'докту': { revenue: { range: 'C103:P103', colors: ['#E78273', '#FFD666', '#57BB8A'] } }, 'тв': { revenue: { range: 'C127:P127', colors: ['#E78273', '#FFD666', '#57BB8A'] } }, 'инстаграм': { leads: { range: 'C149:U149', colors: ['#E78273', '#FFD666', '#57BB8A'] }, revenue: { range: 'C154:P154', colors: ['#E78273', '#FFD666', '#57BB8A'] }, }, }, // Ordinary cell fills and manually highlighted rows on «Дана неделя». // These are not colorScale rules: do not invent numeric thresholds for them. fills: { google: { revenue: { range: 'C58:U58', color: '#83C380' } }, zoon: { leads: { range: 'C89:U89', color: '#E67C73' } }, 'докту': { leads: { range: 'C98:U98', color: '#E98A71' } }, 'тв': { leads: { range: 'C122:U122', color: '#E98A71' } }, 'радио': { leads: { range: 'C131:U131', color: '#E98972' }, revenue: { range: 'C136:U136', color: '#F4CCCC' }, }, 'баннера': { revenue: { range: 'C145:U145', color: '#F4CCCC' } }, 'маршрутки': { revenue: { range: 'C163:U163', color: '#F4CCCC' } }, 'вотсапрассылки': { revenue: { range: 'C172:U172', color: '#F4CCCC' } }, 'полиграфия': { revenue: { range: 'C181:U181', color: '#F4CCCC' } }, 'итогоинтернет': { conversion: { range: 'C112:U112', color: '#F4CCCC' } }, }, rule(source, metric) { let key = String(source || '').toLocaleLowerCase('ru-RU').replace(/[\s_-]+/g, ''); key = ({ '2gis': '2гис', 'яндекс': 'яндекскарточка', 'инстаграмм': 'инстаграм', instagram: 'инстаграм' })[key] || key; return this.rules[key]?.[metric]; }, fill(source, metric) { const key = WeeklySourceLayout.key(source); if (this.fills[key]?.[metric]) return this.fills[key][metric]; if (key === 'callcenter') return { range: 'C2:U15', color: '#F9CB9C' }; if (WeeklySourceLayout.sources.some(([name]) => WeeklySourceLayout.key(name) === key)) return { range: 'C17:U187', color: '#EAE5D8' }; return null; }, colors(values, rule, reference = values, labels = [], referenceLabels = labels) { if (!rule) return values.map(() => ''); const periodKey = (label) => String(label || '').replace(/[–—]/g, '-').replace(/\s/g, ''); const separate = (label) => Boolean(rule.separatePeriod && periodKey(label) === rule.separatePeriod); const sorted = (isolated) => reference.filter((v, i) => Number.isFinite(v) && separate(referenceLabels[i]) === isolated).sort((a, b) => a - b); const groups = [sorted(false), sorted(true)]; const rgb = rule.colors.map((color) => [1, 3, 5].map((offset) => parseInt(color.slice(offset, offset + 2), 16))); return values.map((value, i) => { if (!Number.isFinite(value)) return ''; const nums = groups[Number(separate(labels[i]))]; if (!nums.length) return ''; const min = nums[0], max = nums[nums.length - 1]; // PERCENTILE.INC(range, .5): the middle value, or the mean of the two middle values. const median = (nums[Math.floor((nums.length - 1) / 2)] + nums[Math.ceil((nums.length - 1) / 2)]) / 2; // Excel uses the upper colour for a singleton/equal-value scale (e.g. C35). if (min === max || value >= max) return rule.colors[2]; if (value <= min) return rule.colors[0]; if (value === median) return rule.colors[1]; const lowerHalf = value < median; const lower = lowerHalf ? min : median, upper = lowerHalf ? median : max; const from = rgb[lowerHalf ? 0 : 1], to = rgb[lowerHalf ? 1 : 2]; const fraction = (value - lower) / (upper - lower); return `rgb(${from.map((start, component) => Math.round(start + (to[component] - start) * fraction)).join(', ')})`; }); }, }; const WeeklySourceLayout = { // Source order from the reference, with «Медийка» excluded at the user's request. sources: [ ['сайт', 'Сайт'], ['2гис', '2Гис'], ['яндекскарточка', 'Яндекс карточка'], ['google', 'Google карточка'], ['продокторов', 'Продокторов'], ['напоправку', 'НаПоправку'], ['сберздоровье', 'Сберздоровье'], ['zoon', 'ZOON'], ['докту', 'Докту'], ['итого-интернет', 'ИТОГО интернет'], ['тв', 'ТВ'], ['радио', 'Радио'], ['баннера', 'Баннера'], ['инстаграм', 'Инстаграм'], ['маршрутки', 'Маршрутки'], ['вотсапрассылки', 'Вотсап рассылки'], ['полиграфия', 'Полиграфия'], ], key(value) { const key = String(value || '').toLocaleLowerCase('ru-RU').replace(/[\s_-]+/g, ''); return ({ '2gis': '2гис', 'яндекс': 'яндекскарточка', googleкарточка: 'google', 'баннер': 'баннера', 'инстаграмм': 'инстаграм', instagram: 'инстаграм', whatsapp: 'вотсапрассылки', whatsappрассылки: 'вотсапрассылки', 'вотсап': 'вотсапрассылки', 'ватсап': 'вотсапрассылки', 'ватсапрассылки': 'вотсапрассылки', })[key] || key; }, arrange(reports) { const available = []; const seen = new Set(); for (const report of reports) { for (const channel of report.channels) { // Hide this source from the dashboard, including previously saved reports. // The saved channel and its calculations remain untouched. if (this.key(channel.key) === 'медийка' || this.key(channel.title) === 'медийка') continue; if (this.key(channel.key) === 'всеномера' || seen.has(channel.key)) continue; seen.add(channel.key); available.push(channel); } } const result = []; const used = new Set(); for (const [key, title] of this.sources) { const canonical = this.key(key); const matches = available.filter((channel) => !used.has(channel.key) && (this.key(channel.key) === canonical || this.key(channel.title) === canonical)); if (matches.length) { for (const channel of matches) { result.push({ key: channel.key, title }); used.add(channel.key); } } else { result.push({ key, title }); } } // Keep additional real channels; never discard data absent from the template. return result.concat(available.filter((channel) => !used.has(channel.key)).map(({ key, title }) => ({ key, title }))); }, }; const WeeklyDashboard = { list: [], // Страница по ссылке для маркетолога (share.html): только просмотр — без удаления периодов и правки бюджета. readOnly: false, loading: false, error: '', requestId: 0, mode: 'weeks', dateKey(value) { const formatter = new Intl.DateTimeFormat('en-CA', { timeZone: 'Europe/Moscow', year: 'numeric', month: '2-digit', day: '2-digit' }); const parts = Object.fromEntries(formatter.formatToParts(new Date(value)).map(({ type, value }) => [type, value])); return `${parts.year}-${parts.month}-${parts.day}`; }, isMonth(report) { const from = this.dateKey(report.period_from), to = this.dateKey(report.period_to); const [year, month, day] = from.split('-').map(Number); return day === 1 && to === new Date(Date.UTC(year, month, 1)).toISOString().slice(0, 10); }, periods() { return this.list.filter((report) => this.isMonth(report) === (this.mode === 'months')); }, setMode(mode) { if (!['weeks', 'months'].includes(mode) || this.mode === mode) return; this.mode = mode; this.render(); }, periodLabel(report) { if (this.mode !== 'months') return report.label.replace(/[–—]/g, '-'); const label = new Intl.DateTimeFormat('ru-RU', { timeZone: 'Europe/Moscow', month: 'long', year: 'numeric' }).format(new Date(report.period_from)).replace(/\s*г\.$/, ''); return label[0].toLocaleUpperCase('ru-RU') + label.slice(1); }, async open() { await WeeklyDashboard.load(); }, async load() { const requestId = ++WeeklyDashboard.requestId; WeeklyDashboard.loading = true; WeeklyDashboard.error = ''; WeeklyDashboard.render(); try { const r = await api(WeeklyDashboard.readOnly ? '/share/weekly-reports' : ReportClinic.q('/admin/weekly-reports')); if (requestId !== WeeklyDashboard.requestId) return; WeeklyDashboard.list = Array.isArray(r.data) ? r.data : []; } catch (e) { if (requestId !== WeeklyDashboard.requestId) return; WeeklyDashboard.error = e.message || 'Не удалось загрузить отчёты'; toast(WeeklyDashboard.error, true); } finally { if (requestId === WeeklyDashboard.requestId) { WeeklyDashboard.loading = false; WeeklyDashboard.render(); } } }, async setBudget(reportId, channel, raw) { const amount = Number(String(raw).trim().replace(',', '.')); if (!Number.isFinite(amount) || amount < 0) { toast('Бюджет должен быть неотрицательным числом', true); WeeklyDashboard.render(); return false; } try { await api(`/admin/weekly-reports/${reportId}/budget`, { method: 'PUT', body: JSON.stringify({ channel, amount }) }); const saved = WeeklyDashboard.list.find((r) => r.id === reportId)?.channels.find((c) => c.key === channel); if (saved) saved.budget = amount; WeeklyDashboard.render(); return true; } catch (e) { toast(e.message, true); WeeklyDashboard.render(); return false; } }, async remove(id) { if (!confirm('Удалить этот период из дашборда?')) return; try { await api('/admin/weekly-reports/' + id, { method: 'DELETE' }); toast('Период удалён'); WeeklyDashboard.load(); } catch (e) { toast(e.message, true); } }, /* ---------- фильтр по датам ---------- */ filtered() { const from = $('#wk-filter-from').value, to = $('#wk-filter-to').value; const periods = WeeklyDashboard.periods(); if (!from && !to) return periods; return periods.filter((r) => { const pf = WeeklyDashboard.dateKey(r.period_from), pt = WeeklyDashboard.dateKey(r.period_to); // Storage uses Moscow midnight and an exclusive period_to (next day). if (from && pt <= from) return false; if (to && pf > to) return false; return true; }); }, /* ---------- таблица ---------- */ render() { const all = WeeklyDashboard.periods(); const list = WeeklyDashboard.filtered(); $('#wk-title').textContent = all.length ? `Периодов в дашборде: ${all.length}` + (list.length !== all.length ? ` · показано: ${list.length}` : '') : 'Периодов пока нет'; $('#wk-sub').textContent = all.length ? `С ${all[0].label} по ${all[all.length - 1].label}` : 'Периоды появляются сами: загрузите выгрузку 1С на вкладке «Новый отчёт» — если в ней есть строка «Период ДД.ММ.ГГГГ - ДД.ММ.ГГГГ», результат попадёт и сюда.'; $('#wk-filter').hidden = all.length < 2; // Keep source names/icons visible even if this mode/filter has no periods. $('#wk-board').hidden = false; const sources = WeeklySourceLayout.arrange(WeeklyDashboard.list); const order = sources.map((source) => source.key); const titleByKey = Object.fromEntries(sources.map((source) => [source.key, source.title])); const fmtN = (v) => Math.round(v || 0).toLocaleString('ru-RU'); const fmtRub = (v) => '₽ ' + (v || 0).toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); const fmtPct = (v) => (v || 0).toFixed(1).replace('.', ',') + '%'; const fmtF = (v) => (v || 0).toFixed(2).replace('.', ','); const fmtTime = (sec) => { sec = sec || 0; const m = Math.floor(sec / 60), s = sec % 60; return m + ':' + String(s).padStart(2, '0'); }; const table = $('#wk-table'); table.textContent = ''; const cols = el('colgroup'); for (const name of ['channel', 'metric', ...list.map(() => 'period')]) cols.append(el('col', { className: 'wk-col--' + name })); table.append(cols); const thead = el('thead'); const hr = el('tr'); const brand = el('th', { className: 'wk-brand', colSpan: '2' }); brand.append(el('span', { textContent: ReportClinic.brand }), el('small', { textContent: 'Маркетинговая аналитика' })); hr.append(brand); for (const rep of list) { const th = el('th', { className: 'wk-period' }); th.append(el('span', { textContent: WeeklyDashboard.periodLabel(rep), title: rep.label })); if (!WeeklyDashboard.readOnly) { const del = el('button', { type: 'button', className: 'wk-del', title: 'Удалить период', ariaLabel: `Удалить период ${rep.label}`, textContent: '×' }); del.onclick = () => WeeklyDashboard.remove(rep.id); th.append(del); } hr.append(th); } thead.append(hr); table.append(thead); const tbody = el('tbody'); // Original service icons are served locally, without redrawing or recolouring. const sourceMarks = { 'сайт': ['#83b8a2', 'website', 'svg'], '2гис': ['#17a623', '2gis'], '2gis': ['#17a623', '2gis'], 'яндекс': ['#ff4025', 'yandex'], 'яндекскарточка': ['#ff4025', 'yandex'], 'продокторов': ['#3581fa', 'prodoctorov'], 'напоправку': ['#ffb900', 'napopravku'], 'сберздоровье': ['#20b7c5', 'sber'], zoon: ['#6934ff', 'zoon'], 'докту': ['#12a344', 'doctu', 'svg'], 'инстаграм': ['#e4409b', 'instagram'], 'инстаграмм': ['#e4409b', 'instagram'], instagram: ['#e4409b', 'instagram'], 'вк': ['#0077ff', 'vk'], 'вконтакте': ['#0077ff', 'vk'], vk: ['#0077ff', 'vk'], telegram: ['#26a5e4', 'telegram'], 'телеграм': ['#26a5e4', 'telegram'], google: ['#4285f4', 'google'], max: ['#7354ff', 'max'], }; const utilityMarks = { phone: '', website: '', internet: '', banner: '', tv: '', route: '', radio: '', print: '', video: '', chat: '', generic: '', }; const utilityKeys = { 'сайт': 'website', 'итогоинтернет': 'internet', 'баннер': 'banner', 'баннера': 'banner', 'баннераимаршрутки': 'banner', 'тв': 'tv', 'маршрутки': 'route', 'радио': 'radio', 'полиграфия': 'print', 'ролик': 'video', 'медийка': 'video', 'вотсапрассылки': 'chat', }; const utilityColors = { phone: '#93bfa7', website: '#83b8a2', internet: '#6d9de0', banner: '#e89b3c', tv: '#9a83cb', route: '#6b9dbc', radio: '#d8a47d', print: '#8598a9', video: '#719dcc', chat: '#5daf83', generic: '#6d9de0' }; const sourceTint = (color) => { const match = /^#([\da-f]{6})$/i.exec(color || ''); if (!match) return ''; const rgb = [0, 2, 4].map((offset) => parseInt(match[1].slice(offset, offset + 2), 16)); return `#${rgb.map((value) => Math.round(value + (255 - value) * .955).toString(16).padStart(2, '0')).join('')}`; }; // Номера источников — как в таблице «Показатели Дана», лист своей клиники (подпись под названием канала). const ochevidnoNumbers = { 'сайт': 'Звонок +79285300063\nВотсап +79285300063', '2гис': 'Звонок +79285140077\nВотсап +79285140077', 'яндекскарточка': 'Звонок +79285300213\nВотсап +79285300213', 'продокторов': '+74993254182', 'напоправку': '+74993254273', 'сберздоровье': '+74993254221', 'zoon': '+74993254228', 'тв': '+79285300068', 'баннераимаршрутки': '+79285300066', 'инстаграм': 'Instagram Direct\nВотсап +79285300077', }; const danaNumbers = { 'сайт': 'Звонок +79289620094\nВотсап +79289620094\nЗаявки с сайта', '2гис': 'Звонок 89285440094\nВотсап 89285440094', 'яндекскарточка': 'Звонок 89285088306\nВотсап 89285088306', 'продокторов': '+74993256643', 'напоправку': '+74993256599', 'сберздоровье': '+74993256736', 'zoon': '+74993256585', 'докту': '+74993256742', 'тв': '+7 499 325-72-04', 'радио': '+7 499 325-49-40', 'баннера': '89282970055', 'инстаграм': 'Звонок\n+74993253118 — Эстетик\n+74993253092 — Гинекология\n+74993253084 — Остео\n+74993253079 — Дети\n+74993253016 — Дана медикал\nВотсап\n89280470055', 'маршрутки': '+7 499 325-69-18', 'полиграфия': '+7 499 325-70-82', }; const sourceNumbers = ReportClinic.key === 'ochevidno' ? ochevidnoNumbers : danaNumbers; const channelCell = (title, kind, rows, sourceKey) => { const normalizedKey = WeeklySourceLayout.key(sourceKey); const titleKey = WeeklySourceLayout.key(title); let brand = sourceMarks[normalizedKey] || sourceMarks[titleKey]; // канал «Сайт» — значок сайта своей клиники (у «Очевидно» — с ochevidno.ru); нет значка — общая иконка const siteLogo = { dana: '/favicon.ico?v=20261005-dana-site-icon', ochevidno: 'assets/channels/ochevidno-site.png?v=20261014' }[ReportClinic.key]; if (brand && brand[1] === 'website') brand = siteLogo ? (ReportClinic.key === 'ochevidno' ? ['#26365a', 'website'] : brand) : null; const td = el('td', { className: `wk-channel wk-channel--${kind}`, rowSpan: String(rows) }); // The reference uses cream for 2GIS and a barely tinted blush for Yandex. if (brand && kind !== 'pin' && kind !== 'ya') td.style.setProperty('--wk-tint', sourceTint(brand[0])); const icon = el('span', { className: `wk-channel__icon wk-channel__icon--${brand ? 'brand' : 'utility'}` }); icon.setAttribute('aria-hidden', 'true'); if (brand) { icon.style.setProperty('--wk-source-color', brand[0]); const mark = el('img', { src: brand[1] === 'website' ? siteLogo : `assets/channels/${brand[1]}.${brand[2] || 'png'}?v=20261002-original-icons`, alt: '', width: 32, height: 32, decoding: 'async', }); if (brand[1] === 'doctu') { icon.style.width = '84px'; mark.style.width = '84px'; mark.width = 84; } icon.append(mark); } else { const name = utilityKeys[normalizedKey] || utilityKeys[titleKey] || (kind === 'web' ? 'website' : kind === 'phone' ? 'phone' : 'generic'); td.style.setProperty('--wk-accent', utilityColors[name]); if (kind !== 'phone' && kind !== 'web') td.style.setProperty('--wk-tint', sourceTint(utilityColors[name])); icon.dataset.mark = name; if (name === 'banner') { icon.classList.add('wk-channel__icon--banner'); td.style.setProperty('--wk-accent', '#e89b3c'); td.style.setProperty('--wk-tint', sourceTint('#e89b3c')); } icon.innerHTML = ``; } td.append(icon, el('strong', { className: 'wk-channel__title', textContent: title })); // У колл-центра подписи нет; у источников — их номера (если известны), иначе общая надпись. if (kind !== 'phone') td.append(el('small', { className: 'wk-channel__hint', textContent: sourceNumbers[normalizedKey] || sourceNumbers[titleKey] || 'Канал обращений' })); return td; }; const metricRow = (label, values, channel, channelKind, channelRows, sourceKey, heat, rule, reference, metric) => { const tr = el('tr'); if (channel) tr.append(channelCell(channel, channelKind, channelRows, sourceKey)); tr.append(el('td', { className: 'wk-metric', textContent: label })); const colors = heat ? WeeklyExcelFormatting.colors(heat, rule, reference, list.map((r) => r.label), all.map((r) => r.label)) : []; const fill = WeeklyExcelFormatting.fill(sourceKey, metric); for (const [i, v] of values.entries()) { const td = el('td', { className: `wk-value${colors[i] ? ' wk-heat' : ''}`, textContent: v }); if (colors[i]) { td.style.setProperty('--wk-heat', colors[i]); td.title = `Как в Excel: минимум → медиана (50-й процентиль) → максимум. Диапазон: вся история этого показателя и канала. Правило: ${rule.range}`; } else if (fill && v !== '—' && v !== '') { td.classList.add('wk-fill'); td.style.background = fill.color; td.title = `Заливка строки из Excel: ${fill.range}. Без градиента по значениям.`; } tr.append(td); } return tr; }; const appendGap = () => { const gap = el('tr', { className: 'wk-gap', ariaHidden: 'true' }); gap.append(el('td', { colSpan: String(list.length + 2) })); tbody.append(gap); }; // У недель, добавленных из внешней таблицы, колл-центра нет (в базе пустой объект) — показываем «—», а не нули. const hasCC = (r) => ['total', 'incoming', 'outgoing', 'unique', 'patients', 'revenue'].some((k) => (r.callcenter && r.callcenter[k]) > 0); const cc = (format) => (r) => hasCC(r) ? format(r) : '—'; const ccHeat = (pick) => (r) => hasCC(r) ? pick(r) : NaN; const ccRows = [ ['Всего звонков', cc((r) => fmtN(r.callcenter.total))], ['Входящих', cc((r) => fmtN(r.callcenter.incoming))], ['Исходящих', cc((r) => fmtN(r.callcenter.outgoing))], ['Не принятых', cc((r) => fmtN(r.callcenter.missed))], ['Доля пропущенных', cc((r) => fmtPct(r.callcenter.missed_pct))], ['Уникальных позвонивших', cc((r) => fmtN(r.callcenter.unique)), ccHeat((r) => r.callcenter.unique), 'unique'], ['Количество пациентов', cc((r) => fmtN(r.callcenter.patients))], ['Реализованных услуг', cc((r) => fmtN(r.callcenter.services))], ['Выручка', cc((r) => fmtRub(r.callcenter.revenue)), ccHeat((r) => r.callcenter.revenue), 'revenue'], ['Средний чек', cc((r) => fmtRub(r.callcenter.avg_check))], ['Среднее время разговора', cc((r) => fmtTime(r.callcenter.talk_seconds))], ['Среднее ожидание ответа', cc((r) => fmtTime(r.callcenter.wait_seconds))], ]; ccRows.forEach(([label, format, heat, metric], i) => tbody.append(metricRow(label, list.map(format), i === 0 ? 'Колл-центр' : null, 'phone', ccRows.length, 'callcenter', heat ? list.map(heat) : null, WeeklyExcelFormatting.rule('callcenter', metric), heat ? all.map(heat) : null, metric))); if (order.length) appendGap(); for (const key of order) { const byRep = list.map((rep) => rep.channels.find((c) => c.key === key)); const allByRep = all.map((rep) => rep.channels.find((c) => c.key === key)); const rows = 8; const kind = /все номера|колл/i.test(titleByKey[key]) ? 'phone' : /2гис/i.test(titleByKey[key]) ? 'pin' : /яндекс/i.test(titleByKey[key]) ? 'ya' : /сайт/i.test(titleByKey[key]) ? 'web' : 'source'; const rowsData = [ ['Лиды', (c) => c ? fmtN(c.leads) : '—', (c) => c ? c.leads : NaN, 'leads'], ['Количество пациентов', (c) => c ? fmtN(c.patients) : '—'], ['Конверсия звонка в пациента', (c) => c && c.leads ? fmtPct((c.patients / c.leads) * 100) : '—', null, 'conversion'], ['Количество реализованных услуг', (c) => c ? fmtN(c.services) : '—'], ['Среднее кол. услуг/пациент', (c) => c && c.patients ? fmtF(c.services / c.patients) : '—'], ['Выручка', (c) => c ? fmtRub(c.revenue) : '—', (c) => c ? c.revenue : NaN, 'revenue'], ['Средний чек', (c) => c ? fmtRub(c.avg_check) : '—'], ]; rowsData.forEach(([label, format, heat, metric], i) => tbody.append(metricRow(label, byRep.map(format), i === 0 ? titleByKey[key] : null, kind, rows, key, heat ? byRep.map(heat) : null, WeeklyExcelFormatting.rule(key, metric), heat ? allByRep.map(heat) : null, metric))); const budgetRow = el('tr'); budgetRow.append(el('td', { className: 'wk-metric', textContent: 'Бюджет' })); byRep.forEach((c, i) => { const td = el('td', { className: 'wk-value' }); if (c && key !== 'итого-интернет' && WeeklyDashboard.readOnly) { const fill = WeeklyExcelFormatting.fill(key, 'budget'); if (fill) td.style.background = fill.color; td.textContent = c.budget ? fmtRub(c.budget) : '—'; } else if (c && key !== 'итого-интернет') { const fill = WeeklyExcelFormatting.fill(key, 'budget'); if (fill) td.style.background = fill.color; const inp = el('input', { type: 'number', min: '0', step: '0.01', value: c.budget ?? '', placeholder: '—', className: 'wk-budget', ariaLabel: `Бюджет: ${titleByKey[key]}, ${list[i].label}` }); inp.onchange = async () => { inp.disabled = true; await WeeklyDashboard.setBudget(list[i].id, key, inp.value); }; td.append(inp); } else { td.textContent = '—'; } budgetRow.append(td); }); tbody.append(budgetRow); if (key !== order.at(-1)) { appendGap(); } } table.append(tbody); if (typeof WeeklyFullscreen !== 'undefined') WeeklyFullscreen.sync(); }, }; $('#wk-filter-from').onchange = () => WeeklyDashboard.render(); $('#wk-filter-to').onchange = () => WeeklyDashboard.render(); $('#wk-filter-reset').onclick = () => { $('#wk-filter-from').value = ''; $('#wk-filter-to').value = ''; WeeklyDashboard.render(); };