wasrusgen1-crm/Mokap/data.js

737 lines
43 KiB
JavaScript
Raw 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.

/**
* @wasrusgen1 CRM — Единый источник данных
* Подключается во все кабинеты: <script src="data.js"></script>
*
* Реализация контракта SCHEMA.md v1.1 (2026-05-29).
* Единый источник истины по структуре — SCHEMA.md. При расхождении правится код.
*
* Порядок объявления (раздел 1, БП-9):
* _DICT → config → salons → users → clients → orders →
* appointments → requests → shiftRequests → ratings → хелперы → backward-compat
*
* Базовая дата демо-данных: 2026-05-22 (шахматка), период статистики — Май 2026.
* Версия: 2026-05-29 (v1.1)
*/
// ═══════════════════════════════════════════════════════════════════
// 3. СПРАВОЧНИКИ (_DICT) — все enum зафиксированы (разделы 3.13.14)
// ═══════════════════════════════════════════════════════════════════
var _DICT = {
// 3.1 role
role: {
owner: { label: 'КД', desc: 'Собственник, видит всю сеть. salonId=null.' },
admin: { label: 'Администратор', desc: 'Директор одного салона.' },
manager: { label: 'Менеджер', desc: 'Продавец-консультант.' },
measurer: { label: 'Замерщик', desc: 'Проводит замеры.' },
assembler: { label: 'Сборщик', desc: 'Выполняет монтаж.' },
},
// 3.2 orderStatus
orderStatus: {
lead: { label: 'Лид', color: '#94A3B8' },
measuring: { label: 'Замер', color: '#3B82F6' },
project: { label: 'Проект', color: '#8B5CF6' },
tech: { label: 'Техника', color: '#0891B2' },
technolog: { label: 'Технолог', color: '#D97706' },
production: { label: 'Производство', color: '#7C3AED' },
assembly: { label: 'Сборка', color: '#059669' },
done: { label: 'Закрыт', color: '#16A34A' },
canceled: { label: 'Отменён', color: '#DC2626' },
},
// 3.3 paymentStatus
paymentStatus: {
none: { label: 'Без оплаты', color: '#94A3B8' },
partial: { label: 'Частичная', color: '#D97706' },
paid: { label: 'Оплачен', color: '#16A34A' },
refunded: { label: 'Возврат', color: '#DC2626' },
},
// 3.4 clientSource
clientSource: {
walkin: { label: 'Зашёл в салон' },
instagram: { label: 'Instagram' },
avito: { label: 'Avito' },
referral: { label: 'Рекомендация' },
site: { label: 'Сайт' },
other: { label: 'Другое' },
},
// 3.5 clientStatus
clientStatus: {
lead: { label: 'Лид', color: '#94A3B8' },
active: { label: 'Активный', color: '#3B82F6' },
repeat: { label: 'Повторный', color: '#16A34A' },
lost: { label: 'Потерян', color: '#DC2626' },
},
// 3.6 appointmentType
appointmentType: {
consult: { label: 'Консультация', color: '#3B82F6' },
follow: { label: 'Повторный контакт', color: '#0891B2' },
measure: { label: 'Замер', color: '#D97706' },
tech: { label: 'Техническая', color: '#8B5CF6' },
},
// 3.7 appointmentStatus (нет 'free' — свобода = отсутствие записи)
appointmentStatus: {
busy: { label: 'Занято', color: '#3B82F6' },
done: { label: 'Завершено', color: '#16A34A' },
noshow: { label: 'Не пришёл', color: '#DC2626' },
canceled: { label: 'Отменено', color: '#94A3B8' },
},
// 3.8 requestType
requestType: {
supply: { label: 'Снабжение' },
escalate: { label: 'Эскалация' },
visit: { label: 'Выезд/транспорт' },
schedule: { label: 'Расписание' },
other: { label: 'Другое' },
},
// 3.9 priority
priority: {
low: { label: 'Низкий', color: '#94A3B8' },
normal: { label: 'Обычный', color: '#3B82F6' },
high: { label: 'Высокий', color: '#DC2626' },
},
// 3.10 requestStatus
requestStatus: {
new: { label: 'Новая', color: '#3B82F6' },
inWork: { label: 'В работе', color: '#D97706' },
done: { label: 'Выполнена', color: '#16A34A' },
rejected: { label: 'Отклонена', color: '#DC2626' },
},
// 3.11 shiftRequestType
shiftRequestType: {
swap: { label: 'Обмен сменами' },
off: { label: 'Отгул' },
extra: { label: 'Доп. смена' },
},
// 3.12 shiftRequestStatus
shiftRequestStatus: {
pending: { label: 'На рассмотрении', color: '#D97706' },
approved: { label: 'Одобрено', color: '#16A34A' },
declined: { label: 'Отклонено', color: '#DC2626' },
},
// 3.13 ratingStage
ratingStage: {
measuring: { label: 'Замер' },
project: { label: 'Проект' },
assembly: { label: 'Сборка' },
order: { label: 'Весь заказ' },
},
// 3.14 ratingCriteria
ratingCriteria: {
quality: { label: 'Качество работы' },
speed: { label: 'Скорость' },
cleanliness: { label: 'Чистота' },
deadlines: { label: 'Сроки' },
communication: { label: 'Взаимодействие' },
overall: { label: 'Общая оценка' },
service: { label: 'Сервис' },
result: { label: 'Результат' },
},
};
// ═══════════════════════════════════════════════════════════════════
// 1.7 config — конфигурация и комиссии (singleton)
// ═══════════════════════════════════════════════════════════════════
var config = {
kpiNorm: 80,
currency: 'RUB',
timezone: 'Europe/Moscow',
workdayStart: '10:00',
workdayEnd: '20:00',
slotMinutes: 60,
commissionRules: [
{ role: 'manager', rate: 0.07 },
],
};
// ═══════════════════════════════════════════════════════════════════
// 1.1 salons — салоны сети
// ═══════════════════════════════════════════════════════════════════
var salons = [
{ id: 'sal_lenina', name: 'Салон на Ленина', address: 'пр. Ленина, 42',
color: '#3B82F6', adminId: 'usr_adm_lenina', revenuePlan: 1700000, ordersPlan: 30, active: true },
{ id: 'sal_pobedy', name: 'Салон на Победы', address: 'пр. Победы, 12',
color: '#8B5CF6', adminId: 'usr_adm_pobedy', revenuePlan: 1500000, ordersPlan: 22, active: true },
];
// ═══════════════════════════════════════════════════════════════════
// 1.2 users — сотрудники (все роли). KPI здесь НЕ хранятся (БП-7).
// ═══════════════════════════════════════════════════════════════════
var users = [
// КД (salonId = null, над салонами — БП-12)
{ id: 'usr_owner', name: 'Васильев Руслан', initials: 'ВР', role: 'owner',
salonId: null, color: '#0F0F1A', phone: '+79110000001', active: true, commissionRate: null },
// Администраторы салонов
{ id: 'usr_adm_lenina', name: 'Анна Морозова', initials: 'АМ', role: 'admin',
salonId: 'sal_lenina', color: '#1E40AF', phone: '+79110000010', active: true, commissionRate: null },
{ id: 'usr_adm_pobedy', name: 'Ирина Соколова', initials: 'ИС', role: 'admin',
salonId: 'sal_pobedy', color: '#5B21B6', phone: '+79110000011', active: true, commissionRate: null },
// Менеджеры (стабильные id; инициалы — только отображение, БП-9)
{ id: 'usr_001', name: 'Анна Кузнецова', initials: 'АК', role: 'manager',
salonId: 'sal_lenina', color: '#7C3AED', phone: '+79110000101', active: true, commissionRate: null },
{ id: 'usr_002', name: 'Мария Смирнова', initials: 'МС', role: 'manager',
salonId: 'sal_lenina', color: '#0891B2', phone: '+79110000102', active: true, commissionRate: null },
{ id: 'usr_003', name: 'Пётр Васнецов', initials: 'ПВ', role: 'manager',
salonId: 'sal_pobedy', color: '#059669', phone: '+79110000103', active: true, commissionRate: null },
{ id: 'usr_004', name: 'Иван Власов', initials: 'ИВ', role: 'manager',
salonId: 'sal_pobedy', color: '#D97706', phone: '+79110000104', active: false, commissionRate: null },
];
// ═══════════════════════════════════════════════════════════════════
// 1.3 clients — клиенты (сущность, не строка). Телефон уникален (БП-6).
// Источники: заказы (3), лиды (3), клиенты шахматки (cli_new_*).
// ═══════════════════════════════════════════════════════════════════
var clients = [
// ── Из заказов ───────────────────────────────────────────────────
{ id: 'cli_001', name: 'Иванова А.С.', phone: '+7 912 345-67-89', email: null,
source: 'walkin', salonId: 'sal_lenina', managerId: 'usr_001', status: 'active',
createdAt: '2025-05-01T10:00:00+03:00', note: 'Кухня · Ул. Ленина 34, кв.12' },
{ id: 'cli_002', name: 'Петров В.Н.', phone: '+7 903 211-44-55', email: null,
source: 'walkin', salonId: 'sal_lenina', managerId: 'usr_001', status: 'active',
createdAt: '2025-04-16T10:00:00+03:00', note: 'Шкаф-купе · Пр. Мира 7, кв.18' },
{ id: 'cli_003', name: 'Сидорова М.К.', phone: '+7 916 888-22-11', email: null,
source: 'referral', salonId: 'sal_lenina', managerId: 'usr_001', status: 'repeat',
createdAt: '2025-03-08T10:00:00+03:00', note: 'Кухня · Ул. Садовая 5, кв.3' },
// ── Лиды ─────────────────────────────────────────────────────────
{ id: 'cli_004', name: 'Новиков Д.В.', phone: '+7 916 542-11-88', email: null,
source: 'other', salonId: 'sal_lenina', managerId: 'usr_001', status: 'lead',
createdAt: '2026-05-20T11:00:00+03:00', note: 'Кухня · Пр. Победы 12. Источник: Звонок' },
{ id: 'cli_005', name: 'Белова К.И.', phone: '+7 903 774-55-22', email: null,
source: 'walkin', salonId: 'sal_lenina', managerId: 'usr_001', status: 'lead',
createdAt: '2026-05-23T11:00:00+03:00', note: 'Шкаф-купе · адрес уточняется. Источник: Зал' },
{ id: 'cli_006', name: 'Воронова Е.С.', phone: '+7 926 318-77-44', email: null,
source: 'walkin', salonId: 'sal_lenina', managerId: 'usr_001', status: 'lead',
createdAt: '2026-05-22T11:00:00+03:00', note: 'Кухня + гостиная. Источник: Зал' },
// ── Клиенты из шахматки (раздел appointments) ────────────────────
{ id: 'cli_new_01', name: 'Орлова М.', phone: '+7 921 100-00-01', email: null,
source: 'walkin', salonId: 'sal_lenina', managerId: 'usr_001', status: 'active',
createdAt: '2026-05-22T10:00:00+03:00', note: null },
{ id: 'cli_new_02', name: 'Соколов А.', phone: '+7 921 100-00-02', email: null,
source: 'walkin', salonId: 'sal_lenina', managerId: 'usr_001', status: 'active',
createdAt: '2026-05-22T11:00:00+03:00', note: null },
{ id: 'cli_new_03', name: 'Ким Л.', phone: '+7 921 100-00-03', email: null,
source: 'walkin', salonId: 'sal_lenina', managerId: 'usr_001', status: 'active',
createdAt: '2026-05-22T14:00:00+03:00', note: null },
{ id: 'cli_new_04', name: 'Захаров П.', phone: '+7 921 100-00-04', email: null,
source: 'walkin', salonId: 'sal_lenina', managerId: 'usr_001', status: 'active',
createdAt: '2026-05-22T16:00:00+03:00', note: null },
{ id: 'cli_new_05', name: 'Громов И.', phone: '+7 921 100-00-05', email: null,
source: 'walkin', salonId: 'sal_lenina', managerId: 'usr_001', status: 'active',
createdAt: '2026-05-22T19:00:00+03:00', note: null },
{ id: 'cli_new_06', name: 'Козлов Р.', phone: '+7 921 100-00-06', email: null,
source: 'walkin', salonId: 'sal_lenina', managerId: 'usr_002', status: 'active',
createdAt: '2026-05-22T10:00:00+03:00', note: null },
{ id: 'cli_new_07', name: 'Лебедев С.', phone: '+7 921 100-00-07', email: null,
source: 'walkin', salonId: 'sal_lenina', managerId: 'usr_002', status: 'active',
createdAt: '2026-05-22T12:00:00+03:00', note: null },
{ id: 'cli_new_08', name: 'Петрова А.', phone: '+7 921 100-00-08', email: null,
source: 'walkin', salonId: 'sal_lenina', managerId: 'usr_002', status: 'active',
createdAt: '2026-05-22T14:00:00+03:00', note: null },
{ id: 'cli_new_09', name: 'Морозов В.', phone: '+7 921 100-00-09', email: null,
source: 'walkin', salonId: 'sal_lenina', managerId: 'usr_002', status: 'active',
createdAt: '2026-05-22T17:00:00+03:00', note: null },
];
// ═══════════════════════════════════════════════════════════════════
// 1.4 orders — заказы (центральная сущность).
// status — СТРОКА по маппингу (НЕ число). isOverdue/balance — вычисляются.
// Расширенные поля мокапа менеджера (label, tech, advances, zovStatus...)
// сохранены как доп. свойства — схема их не запрещает, UI на них опирается.
// ═══════════════════════════════════════════════════════════════════
var orders = [
// Заказ 1: Иванова А.С. — stage 3 → 'tech'
{ id: 'ord_001', clientId: 'cli_001', managerId: 'usr_001', salonId: 'sal_lenina',
status: 'tech', amount: 186000, prepayment: 93000, commissionRate: null,
createdAt: '2025-05-03T10:00:00+03:00', dueDate: null, closedAt: null,
paymentStatus: 'partial',
// — доп. данные мокапа менеджера —
type: 'kitchen', label: 'Кухня · Ул. Ленина 34, кв.12', contract: 'МБ-2025-041',
assembly: 28000, assemblyPaid: false,
advances: [
{ label: 'Аванс 1 · 50% при подписании', amount: 93000, paid: true, date: '03.05.2025' },
{ label: 'Аванс 2 · при готовности к отгрузке', amount: 93000, paid: false, date: null },
],
rooms: ['Кухня'],
tech: [
{ name: 'Духовой шкаф', wkey: 'oven', status: 'wait', dims: '', brand: '', model: '', source: null },
{ name: 'Варочная панель', wkey: 'cooktop', status: 'done', dims: '60×52 см', brand: 'Bosch', model: 'PUE611BB5E', source: 'ai' },
{ name: 'Вытяжка', wkey: 'hood', status: 'client_chosen', dims: '60 см', brand: 'Elica', model: 'FLAT GLASS IX/A/60', source: 'client' },
{ name: 'Посудомойка', wkey: 'dishwasher', status: 'wait', dims: '', brand: '', model: '', source: null },
{ name: 'Холодильник', wkey: 'fridge', status: 'wait', dims: '', brand: '', model: '', source: null },
],
techNote: 'Ждём габариты духовки и посудомойки. Передано клиенту 10.05.',
blocker: 'Техника: ждём 2 позиции',
zovContract: 'ЗОВ-25-041',
zovStatus: { code: 'production', label: 'В производстве', detail: 'Корпуса готовы. Фасады в работе.', date: '27.05.2026 · 14:33', pct: 45 } },
// Заказ 2: Петров В.Н. — stage 4 → 'technolog'
{ id: 'ord_002', clientId: 'cli_002', managerId: 'usr_001', salonId: 'sal_lenina',
status: 'technolog', amount: 94000, prepayment: 47000, commissionRate: null,
createdAt: '2025-04-18T10:00:00+03:00', dueDate: null, closedAt: null,
paymentStatus: 'partial',
type: 'wardrobe', label: 'Шкаф-купе · Пр. Мира 7, кв.18', contract: 'МБ-2025-038',
assembly: 14000, assemblyPaid: false,
advances: [
{ label: 'Аванс 1 · 50% при подписании', amount: 47000, paid: true, date: '18.04.2025' },
{ label: 'Аванс 2 · при готовности к отгрузке', amount: 47000, paid: false, date: null },
],
rooms: ['Спальня'], tech: [], techNote: '',
blocker: 'Технолог: есть замечания',
zovContract: 'ЗОВ-25-038',
zovStatus: { code: 'ready', label: 'Готов к отгрузке', detail: 'Все позиции готовы. Дата доставки уточняется.', date: '26.05.2026 · 09:15', pct: 90 } },
// Заказ 3: Сидорова М.К. — stage 5 → 'production'
{ id: 'ord_003', clientId: 'cli_003', managerId: 'usr_001', salonId: 'sal_lenina',
status: 'production', amount: 212000, prepayment: 106000, commissionRate: null,
createdAt: '2025-03-10T10:00:00+03:00', dueDate: null, closedAt: null,
paymentStatus: 'paid',
type: 'kitchen', label: 'Кухня · Ул. Садовая 5, кв.3', contract: 'МБ-2025-029',
assembly: 32000, assemblyPaid: true,
advances: [
{ label: 'Аванс 1 · 50% при подписании', amount: 106000, paid: true, date: '10.03.2025' },
{ label: 'Аванс 2 · при готовности к отгрузке', amount: 106000, paid: true, date: '08.05.2025' },
],
rooms: ['Кухня', 'Балкон'], tech: [], techNote: '',
blocker: null,
zovContract: 'ЗОВ-25-029',
zovStatus: { code: 'shipped', label: 'Отгружен', detail: 'Доставлен 09.05.2026. Договор закрыт.', date: '09.05.2026 · 11:20', pct: 100 } },
// Лид 4: Новиков Д.В. → status 'lead', leadStage 'kp'
{ id: 'ord_004', clientId: 'cli_004', managerId: 'usr_001', salonId: 'sal_lenina',
status: 'lead', amount: 0, prepayment: 0, commissionRate: null,
createdAt: '2026-05-20T11:00:00+03:00', dueDate: null, closedAt: null,
paymentStatus: 'none',
isLead: true, type: 'kitchen', label: 'Кухня · Пр. Победы 12',
leadStage: 'kp', source: 'Звонок', sourceDate: '20.05.2026',
leadNote: 'КП отправлено 21.05. Ждём ответа.' },
// Лид 5: Белова К.И. → status 'lead', leadStage 'meeting'
{ id: 'ord_005', clientId: 'cli_005', managerId: 'usr_001', salonId: 'sal_lenina',
status: 'lead', amount: 0, prepayment: 0, commissionRate: null,
createdAt: '2026-05-23T11:00:00+03:00', dueDate: null, closedAt: null,
paymentStatus: 'none',
isLead: true, type: 'wardrobe', label: 'Шкаф-купе · адрес уточняется',
leadStage: 'meeting', source: 'Зал', sourceDate: '23.05.2026',
leadNote: 'Первичный визит сегодня. Встреча в 15:00.' },
// Лид 6: Воронова Е.С. → status 'lead', leadStage 'thinking'
{ id: 'ord_006', clientId: 'cli_006', managerId: 'usr_001', salonId: 'sal_lenina',
status: 'lead', amount: 0, prepayment: 0, commissionRate: null,
createdAt: '2026-05-22T11:00:00+03:00', dueDate: null, closedAt: null,
paymentStatus: 'none',
isLead: true, type: 'kitchen', label: 'Кухня + гостиная',
leadStage: 'thinking', source: 'Зал', sourceDate: '22.05.2026',
leadNote: 'Думает. Сказала перезвонит на следующей неделе.' },
];
// ═══════════════════════════════════════════════════════════════════
// 1.5 appointments — расписание (заменяет _CHESS_DATA).
// Базовая дата шахматки: 2026-05-22. Длительность = endAt startAt (БП-3).
// ═══════════════════════════════════════════════════════════════════
var appointments = [
// ── Анна К. (usr_001), Ленина ──
{ id: 'apt_001', managerId: 'usr_001', clientId: 'cli_new_01', salonId: 'sal_lenina',
type: 'consult', status: 'done', startAt: '2026-05-22T10:00:00+03:00', endAt: '2026-05-22T11:00:00+03:00', orderId: null },
{ id: 'apt_002', managerId: 'usr_001', clientId: 'cli_new_02', salonId: 'sal_lenina',
type: 'follow', status: 'done', startAt: '2026-05-22T11:00:00+03:00', endAt: '2026-05-22T12:00:00+03:00', orderId: null },
{ id: 'apt_003', managerId: 'usr_001', clientId: 'cli_new_03', salonId: 'sal_lenina',
type: 'consult', status: 'busy', startAt: '2026-05-22T14:00:00+03:00', endAt: '2026-05-22T16:00:00+03:00', orderId: null },
{ id: 'apt_004', managerId: 'usr_001', clientId: 'cli_new_04', salonId: 'sal_lenina',
type: 'measure', status: 'busy', startAt: '2026-05-22T16:00:00+03:00', endAt: '2026-05-22T17:00:00+03:00', orderId: null },
{ id: 'apt_005', managerId: 'usr_001', clientId: 'cli_new_05', salonId: 'sal_lenina',
type: 'follow', status: 'busy', startAt: '2026-05-22T19:00:00+03:00', endAt: '2026-05-22T20:00:00+03:00', orderId: null },
// ── Мария С. (usr_002), Ленина ──
{ id: 'apt_006', managerId: 'usr_002', clientId: 'cli_new_06', salonId: 'sal_lenina',
type: 'follow', status: 'done', startAt: '2026-05-22T10:00:00+03:00', endAt: '2026-05-22T11:00:00+03:00', orderId: null },
{ id: 'apt_007', managerId: 'usr_002', clientId: 'cli_new_07', salonId: 'sal_lenina',
type: 'consult', status: 'done', startAt: '2026-05-22T12:00:00+03:00', endAt: '2026-05-22T13:00:00+03:00', orderId: null },
{ id: 'apt_008', managerId: 'usr_002', clientId: 'cli_new_08', salonId: 'sal_lenina',
type: 'tech', status: 'busy', startAt: '2026-05-22T14:00:00+03:00', endAt: '2026-05-22T16:00:00+03:00', orderId: null },
{ id: 'apt_009', managerId: 'usr_002', clientId: 'cli_new_09', salonId: 'sal_lenina',
type: 'consult', status: 'busy', startAt: '2026-05-22T17:00:00+03:00', endAt: '2026-05-22T18:00:00+03:00', orderId: null },
];
// ═══════════════════════════════════════════════════════════════════
// 1.6 requests — заявки менеджеров (заменяет _MGR_REQUESTS).
// created → ISO (БП-1): 'сегодня'→2026-05-22, 'вчера'→2026-05-21.
// ═══════════════════════════════════════════════════════════════════
var requests = [
{ id: 'req_001', authorId: 'usr_001', salonId: 'sal_lenina', type: 'supply', priority: 'high',
title: 'Закончились образцы ткани',
body: 'Нет образцов искусственной замши — теряем клиентов.',
status: 'new', createdAt: '2026-05-22T09:14:00+03:00', assigneeId: null },
{ id: 'req_002', authorId: 'usr_002', salonId: 'sal_lenina', type: 'escalate', priority: 'high',
title: 'Конфликт с клиентом Козлов Р.',
body: 'Клиент требует возврат 25 000 ₽ из-за задержки поставки.',
status: 'new', createdAt: '2026-05-22T10:30:00+03:00', assigneeId: null },
{ id: 'req_003', authorId: 'usr_003', salonId: 'sal_pobedy', type: 'visit', priority: 'normal',
title: 'Нужна машина для выезда к клиенту',
body: 'Клиент Сидорова на Васильевском — нужен транспорт на 15:00.',
status: 'new', createdAt: '2026-05-22T11:05:00+03:00', assigneeId: null },
{ id: 'req_004', authorId: 'usr_001', salonId: 'sal_lenina', type: 'supply', priority: 'normal',
title: 'Каталоги кухонь Hettich закончились',
body: 'Остался 1 экземпляр.',
status: 'done', createdAt: '2026-05-21T17:22:00+03:00', assigneeId: null },
{ id: 'req_005', authorId: 'usr_002', salonId: 'sal_lenina', type: 'schedule', priority: 'normal',
title: 'Запрос на замену смены — 31 мая',
body: 'Прошу разрешить обменяться сменой с Петром.',
status: 'new', createdAt: '2026-05-21T14:10:00+03:00', assigneeId: null },
];
// ═══════════════════════════════════════════════════════════════════
// 4. shiftRequests — запросы по сменам (заменяет _SHIFT_REQS).
// Даты → ISO date (БП-1).
// ═══════════════════════════════════════════════════════════════════
var shiftRequests = [
{ id: 'shr_001', authorId: 'usr_002', salonId: 'sal_lenina', type: 'swap',
withUserId: 'usr_003', dateFrom: '2026-05-31', dateTo: '2026-06-01', status: 'pending' },
{ id: 'shr_002', authorId: 'usr_003', salonId: 'sal_pobedy', type: 'off',
withUserId: null, dateFrom: '2026-06-02', dateTo: null, status: 'pending' },
];
// ═══════════════════════════════════════════════════════════════════
// 1.8 ratings — оценки. Пустой массив для demo (рейтинг вычисляется, БП-15).
// ═══════════════════════════════════════════════════════════════════
var ratings = [];
// ═══════════════════════════════════════════════════════════════════
// ВНУТРЕННИЕ УТИЛИТЫ
// ═══════════════════════════════════════════════════════════════════
/** Заказы менеджера, закрытые (done) за период (фильтр по месяцу closedAt||createdAt). */
function _ordersByMgr(userId) {
return orders.filter(function (o) { return o.managerId === userId; });
}
/** Принадлежит ли ISO-момент периоду. period: 'Май'|'Апр'|'Мар' → 2026-05/04/03. null → любой. */
function _inPeriod(iso, period) {
if (!period) return true;
if (!iso) return false;
var map = { 'Май': '2026-05', 'Апр': '2026-04', 'Мар': '2026-03' };
var pref = map[period];
if (!pref) return true; // неизвестный период — не фильтруем
return String(iso).slice(0, 7) === pref;
}
// Демо-числа для прошлых периодов (нет фактических данных по orders/appointments).
var _DEMO_MGR_STATS = {
'usr_001': { 'Апр': { visits: 38, deals: 11, revenue: 712000, conversion: 29, avg: 64727, kpi: 29 },
'Мар': { visits: 45, deals: 16, revenue: 920000, conversion: 36, avg: 57500, kpi: 36 } },
'usr_002': { 'Апр': { visits: 41, deals: 13, revenue: 780000, conversion: 32, avg: 60000, kpi: 32 },
'Мар': { visits: 39, deals: 12, revenue: 695000, conversion: 31, avg: 57917, kpi: 31 } },
'usr_003': { 'Апр': { visits: 32, deals: 9, revenue: 540000, conversion: 28, avg: 60000, kpi: 28 },
'Мар': { visits: 30, deals: 8, revenue: 520000, conversion: 27, avg: 65000, kpi: 27 } },
'usr_004': { 'Апр': { visits: 29, deals: 7, revenue: 480000, conversion: 24, avg: 68571, kpi: 24 },
'Мар': { visits: 33, deals: 10, revenue: 610000, conversion: 30, avg: 61000, kpi: 30 } },
};
// Демо-агрегаты салонов на текущий месяц (Май) — расширяют то, что реально
// считается из orders, чтобы цифры были «живыми» для прототипа.
var _DEMO_SALON_MAY = {
'sal_lenina': { ordersBase: 27, revenueBase: 1537000, overdueBase: 1, overdueRiskBase: 98500, newLeadsBase: 14 },
'sal_pobedy': { ordersBase: 20, revenueBase: 1310000, overdueBase: 1, overdueRiskBase: 113000, newLeadsBase: 12 },
};
// ═══════════════════════════════════════════════════════════════════
// 5. ВЫЧИСЛЯЕМЫЕ ПОЛЯ (хелперы; НЕ хранятся — БП-7)
// ═══════════════════════════════════════════════════════════════════
/** Просрочен ли заказ: dueDate < сегодня И status ∉ {done, canceled}. */
function isOverdue(order) {
if (!order || !order.dueDate) return false;
if (order.status === 'done' || order.status === 'canceled') return false;
var today = new Date();
var due = new Date(order.dueDate + 'T23:59:59+03:00');
return due < today;
}
/** Остаток к оплате. */
function orderBalance(order) {
return (order.amount || 0) - (order.prepayment || 0);
}
/**
* KPI менеджера за период: visits/deals/revenue/conversion/avg/kpi/commission/belowNorm.
* Май — реальный расчёт из orders+appointments; Апр/Мар — демо-числа.
*/
function getMgrStats(userId, period) {
period = period || 'Май';
if (period !== 'Май') {
var demo = (_DEMO_MGR_STATS[userId] || {})[period];
if (demo) {
var d = Object.assign({}, demo);
d.commission = getCommission(userId, period);
d.belowNorm = d.kpi < config.kpiNorm;
return d;
}
// нет демо — нули
return { visits: 0, deals: 0, revenue: 0, conversion: 0, avg: 0, kpi: 0, commission: 0, belowNorm: true };
}
// Май — реальный расчёт
var visits = appointments.filter(function (a) {
return a.managerId === userId && a.status === 'done' && _inPeriod(a.startAt, period);
}).length;
var doneOrders = _ordersByMgr(userId).filter(function (o) {
return o.status === 'done' && _inPeriod(o.closedAt || o.createdAt, period);
});
var deals = doneOrders.length;
var revenue = doneOrders.reduce(function (s, o) { return s + (o.amount || 0); }, 0);
var conversion = visits > 0 ? Math.round((deals / visits) * 100) : 0;
var avg = deals > 0 ? Math.round(revenue / deals) : 0;
var kpi = conversion;
return {
visits: visits, deals: deals, revenue: revenue, conversion: conversion,
avg: avg, kpi: kpi, commission: getCommission(userId, period),
belowNorm: kpi < config.kpiNorm,
};
}
/**
* Статистика салона за период: orders/revenue/overdue/overdueRisk/newLeads/
* planFulfillment/salonStatus.
*/
function getSalonStats(salonId, period) {
period = period || 'Май';
var salon = _getSalon(salonId);
var salonOrders = orders.filter(function (o) {
return o.salonId === salonId && !o.isLead && _inPeriod(o.createdAt, period);
});
var calcOverdue = salonOrders.filter(isOverdue);
var calcNewLeads = clients.filter(function (c) {
return c.salonId === salonId && c.status === 'lead' && _inPeriod(c.createdAt, period);
}).length;
// Май: наложить демо-базу поверх расчётных (чтобы цифры были полными для прототипа)
var demo = (period === 'Май') ? (_DEMO_SALON_MAY[salonId] || {}) : {};
var ordersCount = demo.ordersBase != null ? demo.ordersBase : salonOrders.length;
var revenue = demo.revenueBase != null ? demo.revenueBase
: salonOrders.filter(function (o) { return o.status === 'done'; })
.reduce(function (s, o) { return s + (o.amount || 0); }, 0);
var overdue = demo.overdueBase != null ? demo.overdueBase : calcOverdue.length;
var overdueRisk = demo.overdueRiskBase != null ? demo.overdueRiskBase
: calcOverdue.reduce(function (s, o) { return s + (o.amount || 0); }, 0);
var newLeads = demo.newLeadsBase != null ? demo.newLeadsBase : calcNewLeads;
var revenuePlan = salon ? salon.revenuePlan : 0;
var planFulfillment = revenuePlan > 0 ? Math.round((revenue / revenuePlan) * 100) : 0;
var salonStatus = 'ok';
if (planFulfillment < 80 || overdue >= 2) salonStatus = 'risk';
else if (planFulfillment < 95 || overdue >= 1) salonStatus = 'warn';
return {
orders: ordersCount, ordersPlan: salon ? salon.ordersPlan : 0,
revenue: revenue, revenuePlan: revenuePlan,
overdue: overdue, overdueRisk: overdueRisk, newLeads: newLeads,
planFulfillment: planFulfillment, salonStatus: salonStatus,
};
}
/** Свободные слоты менеджера на дату (сетка config минус appointments). date: 'YYYY-MM-DD'. */
function getFreeSlots(userId, date) {
var slots = [];
var startH = parseInt(config.workdayStart.split(':')[0], 10);
var endH = parseInt(config.workdayEnd.split(':')[0], 10);
var step = config.slotMinutes / 60;
var taken = {};
appointments.forEach(function (a) {
if (a.managerId !== userId) return;
if (date && a.startAt.slice(0, 10) !== date) return;
var sh = parseInt(a.startAt.slice(11, 13), 10);
var eh = parseInt(a.endAt.slice(11, 13), 10);
for (var h = sh; h < eh; h++) taken[h] = true;
});
for (var hh = startH; hh < endH; hh += step) {
if (!taken[hh]) slots.push(('0' + hh).slice(-2) + ':00');
}
return slots;
}
/** Ставка комиссии по приоритету БП-5: order → user → config[role]. */
function _resolveRate(order, user) {
if (order && order.commissionRate != null) return order.commissionRate;
if (user && user.commissionRate != null) return user.commissionRate;
var rule = config.commissionRules.find(function (r) { return r.role === (user ? user.role : 'manager'); });
return rule ? rule.rate : 0;
}
/** Комиссия менеджера за период (только заказы в статусе done — БП-5). */
function getCommission(userId, period) {
var user = _getMgr(userId) || users.find(function (u) { return u.id === userId; });
var doneOrders = _ordersByMgr(userId).filter(function (o) {
return o.status === 'done' && _inPeriod(o.closedAt || o.createdAt, period);
});
return doneOrders.reduce(function (sum, o) {
return sum + Math.round((o.amount || 0) * _resolveRate(o, user));
}, 0);
}
/** Рейтинг сотрудника = среднее всех scores по ratings.targetId за период (БП-15). */
function employeeRating(userId, period) {
var vals = [];
ratings.forEach(function (r) {
if (r.targetId !== userId) return;
if (!_inPeriod(r.createdAt, period)) return;
Object.keys(r.scores || {}).forEach(function (k) { vals.push(r.scores[k]); });
});
if (!vals.length) return null;
var sum = vals.reduce(function (s, v) { return s + v; }, 0);
return Math.round((sum / vals.length) * 10) / 10;
}
/** Клиентская оценка заказа: ratings stage='order', authorId = clientId заказа. */
function orderRating(orderId) {
var order = orders.find(function (o) { return o.id === orderId; });
if (!order) return null;
var rec = ratings.find(function (r) {
return r.orderId === orderId && r.stage === 'order' && r.authorId === order.clientId;
});
if (!rec) return null;
var vals = Object.keys(rec.scores || {}).map(function (k) { return rec.scores[k]; });
if (!vals.length) return null;
var sum = vals.reduce(function (s, v) { return s + v; }, 0);
return Math.round((sum / vals.length) * 10) / 10;
}
// ═══════════════════════════════════════════════════════════════════
// УТИЛИТЫ-ИСКАЛКИ (совместимость + удобство)
// ═══════════════════════════════════════════════════════════════════
/** Найти салон по id (новая схема). */
function _getSalon(salonId) {
return salons.find(function (s) { return s.id === salonId; }) || null;
}
/** Найти сотрудника по id (новая схема). */
function _getMgr(userId) {
return users.find(function (u) { return u.id === userId; }) || null;
}
/** Клиент по id. */
function _getClient(clientId) {
return clients.find(function (c) { return c.id === clientId; }) || null;
}
// ═══════════════════════════════════════════════════════════════════
// BACKWARD-COMPAT: старые имена для существующих мокапов
// Старые мокапы используют id-инициалы ('ak','ms'...) и матрицу _CHESS_DATA.
// Ниже — вычисляемые алиасы, чтобы они продолжали работать без правок.
// ═══════════════════════════════════════════════════════════════════
// Маппинг новых userId → старые короткие id шахматки.
var _LEGACY_MGR_ID = {
'usr_001': 'ak', 'usr_002': 'ms', 'usr_003': 'pv', 'usr_004': 'iv',
};
var _HIERARCHY = salons.map(function (s) {
var salonMgrs = users.filter(function (u) { return u.salonId === s.id && u.role === 'manager'; });
var stats = getSalonStats(s.id, 'Май');
return {
salonId: s.id, salon: s.name, color: s.color,
admin: (function () {
var a = users.find(function (u) { return u.id === s.adminId; });
return a ? { name: a.name, short: a.initials, userId: a.id } : null;
})(),
orders: stats.orders, ordersPlan: s.ordersPlan,
revenue: stats.revenue, revenuePlan: s.revenuePlan,
overdue: stats.overdue, overdueRisk: stats.overdueRisk,
purchases: 0, newLeads: stats.newLeads, status: stats.salonStatus,
managers: salonMgrs.map(function (m) {
var ms = getMgrStats(m.id, 'Май');
return {
id: _LEGACY_MGR_ID[m.id] || m.id, userId: m.id,
name: m.name, short: m.initials, color: m.color, salon: m.salonId,
visits: ms.visits, deals: ms.deals, revenue: ms.revenue,
conversion: ms.conversion, avg: ms.avg,
rating: employeeRating(m.id, 'Май') != null ? employeeRating(m.id, 'Май') : 4.5,
active: m.active,
};
}),
};
});
var _CHESS_MGRS = (function () {
var list = [];
_HIERARCHY.forEach(function (h) { h.managers.forEach(function (m) { list.push(m); }); });
return list;
})();
var _MONTHLY_STATS = users.filter(function (u) { return u.role === 'manager'; }).map(function (m) {
return {
id: _LEGACY_MGR_ID[m.id] || m.id, userId: m.id,
name: m.name, color: m.color, salon: m.salonId,
months: {
'Май': getMgrStats(m.id, 'Май'),
'Апр': getMgrStats(m.id, 'Апр'),
'Мар': getMgrStats(m.id, 'Мар'),
},
};
});
window._MGR_REQUESTS = window._MGR_REQUESTS || requests;
window._SHIFT_REQS = window._SHIFT_REQS || shiftRequests;
// Сетка часов рабочего дня (для старой шахматки).
var _CHESS_HOURS = (function () {
var startH = parseInt(config.workdayStart.split(':')[0], 10);
var endH = parseInt(config.workdayEnd.split(':')[0], 10);
var arr = [];
for (var h = startH; h <= endH; h++) arr.push(('0' + h).slice(-2) + ':00');
return arr;
})();
// Старая матрица _CHESS_DATA[shortMgrId][HH:00] → {client,type,status} из appointments.
var _CHESS_DATA = (function () {
var out = {};
users.filter(function (u) { return u.role === 'manager'; }).forEach(function (m) {
out[_LEGACY_MGR_ID[m.id] || m.id] = {};
});
appointments.forEach(function (apt) {
var legacyId = _LEGACY_MGR_ID[apt.managerId] || apt.managerId;
if (!out[legacyId]) out[legacyId] = {};
var cli = apt.clientId ? (_getClient(apt.clientId) || {}) : {};
// Многочасовая запись занимает все слоты диапазона.
var sh = parseInt(apt.startAt.slice(11, 13), 10);
var eh = parseInt(apt.endAt.slice(11, 13), 10);
for (var h = sh; h < eh; h++) {
out[legacyId][('0' + h).slice(-2) + ':00'] = {
client: cli.name || '', type: apt.type, status: apt.status,
};
}
});
return out;
})();
// Старый хелпер _getMgrStats(shortId, period) — принимает и короткий, и новый id.
function _getMgrStats(mgrId, period) {
var userId = mgrId;
// если передан короткий id — найти соответствующий новый
Object.keys(_LEGACY_MGR_ID).forEach(function (uid) {
if (_LEGACY_MGR_ID[uid] === mgrId) userId = uid;
});
return getMgrStats(userId, period || 'Май');
}