/* «Дашборд по неделям»: только просмотр накопленной истории периодов. Сами периоды появляются
автоматически при загрузке выгрузки 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();
};