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