dana-medical-site/internal/api/adminui/weekly.js
2026-10-14 13:33:13 +00:00

522 lines
30 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* «Дашборд по неделям»: только просмотр накопленной истории периодов. Сами периоды появляются
автоматически при загрузке выгрузки 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: '<path d="M22 16.9v3a2 2 0 0 1-2.2 2A19.8 19.8 0 0 1 3.1 5.2 2 2 0 0 1 5.1 3h3a2 2 0 0 1 2 1.7c.1.9.3 1.7.6 2.5a2 2 0 0 1-.5 2.1L9 10.6a16 16 0 0 0 4.4 4.4l1.3-1.2a2 2 0 0 1 2.1-.5c.8.3 1.6.5 2.5.6a2 2 0 0 1 2 2Z"/>',
website: '<rect x="2" y="3" width="20" height="18" rx="2.5"/><path d="M2 8h20M5 5.5h.01M8 5.5h.01M11 5.5h.01"/><circle cx="12" cy="14.5" r="4.5"/><path d="M7.5 14.5h9M12 10c1.5 1.5 2 3 2 4.5s-.5 3-2 4.5c-1.5-1.5-2-3-2-4.5s.5-3 2-4.5Z"/>',
internet: '<rect x="3" y="3" width="6" height="6" rx="1.5"/><rect x="15" y="3" width="6" height="6" rx="1.5"/><rect x="3" y="15" width="6" height="6" rx="1.5"/><rect x="15" y="15" width="6" height="6" rx="1.5"/>',
banner: '<rect x="2" y="3" width="20" height="14" rx="2.5" style="fill:#fff3dd;stroke:#d9953f"/><path d="M7 17v4m10-4v4M5 21h4m6 0h4" style="stroke:#547994"/><circle cx="16.5" cy="7" r="1.5" style="fill:#f5bf55;stroke:none"/><path d="m4.5 14 4-5 3 3 2-2 6 4Z" style="fill:#8dc9ce;stroke:none"/><path d="M5 6h5" style="stroke:#db9b4a"/>',
tv: '<rect x="3" y="6" width="18" height="14" rx="2"/><path d="m8 2 4 4 4-4"/>',
route: '<rect x="5" y="3" width="14" height="16" rx="3"/><path d="M5 11h14M8 19v2m8-2v2M8 15h.01M16 15h.01"/>',
radio: '<rect x="3" y="8" width="18" height="12" rx="2"/><path d="m5 8 14-5M15 12h3m-3 4h3"/><circle cx="8" cy="14" r="2"/>',
print: '<path d="M6 8V3h12v5M6 17H3v-7h18v7h-3"/><path d="M6 14h12v7H6zM17 11h.01"/>',
video: '<rect x="3" y="4" width="18" height="16" rx="3"/><path d="m10 8 6 4-6 4Z"/>',
chat: '<path d="M21 11.5a9 9 0 0 1-13.4 7.9L3 21l1.6-4.6A9 9 0 1 1 21 11.5Z"/><path d="M8 7.5c-.8 2.6 2 5.4 4.6 6.2l1.5-1.4 2 .9c.2 1.2-.8 2.2-2 2.2-3.5-.2-7.1-3.8-7.3-7.3 0-1.2 1-2.2 2.2-2l.9 2Z"/>',
generic: '<path d="M4 11V5h6l10 10-5 5L4 11Z"/><circle cx="8" cy="8" r="1"/>',
};
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 = `<svg viewBox="0 0 24 24" aria-hidden="true">${utilityMarks[name]}</svg>`;
}
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();
};