mirror of
https://github.com/wasrusgen/wasrusgen1-crm.git
synced 2026-06-03 15:44:45 +00:00
feat: data.js v2 — SCHEMA v1.1, 8 сущностей, хелперы, backward-compat
This commit is contained in:
parent
7d6494499d
commit
acc34cb148
736
Mokap/data.js
Normal file
736
Mokap/data.js
Normal file
@ -0,0 +1,736 @@
|
|||||||
|
/**
|
||||||
|
* @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.1–3.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 || 'Май');
|
||||||
|
}
|
||||||
829
docs/data.js
829
docs/data.js
@ -1,161 +1,736 @@
|
|||||||
/**
|
/**
|
||||||
* @wasrusgen1 CRM — Единый источник данных
|
* @wasrusgen1 CRM — Единый источник данных
|
||||||
* Подключается во все кабинеты: <script src="data.js"></script>
|
* Подключается во все кабинеты: <script src="data.js"></script>
|
||||||
* Содержит: вертикаль управления, менеджеры, расписание, заявки, статистика
|
*
|
||||||
* Версия: 2026-05-28
|
* Реализация контракта 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.1–3.14)
|
||||||
// Единый источник истины для всех кабинетов
|
|
||||||
// ═══════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════
|
||||||
var _HIERARCHY = [
|
var _DICT = {
|
||||||
{
|
// 3.1 role
|
||||||
salonId:'lenina', salon:'Салон Ленина', color:'#3B82F6',
|
role: {
|
||||||
admin:{name:'Анна М.', short:'АМ', userId:'admin_lenina'},
|
owner: { label: 'КД', desc: 'Собственник, видит всю сеть. salonId=null.' },
|
||||||
orders:27, ordersPlan:30, revenue:1537000, revenuePlan:1700000,
|
admin: { label: 'Администратор', desc: 'Директор одного салона.' },
|
||||||
overdue:1, overdueRisk:98500, purchases:2, newLeads:14, status:'warn',
|
manager: { label: 'Менеджер', desc: 'Продавец-консультант.' },
|
||||||
managers:[
|
measurer: { label: 'Замерщик', desc: 'Проводит замеры.' },
|
||||||
{id:'ak', name:'Анна К.', short:'АК', color:'#7C3AED', salon:'lenina', visits:42, deals:14, revenue:847000, conversion:33, avg:60500, rating:4.8, active:true},
|
assembler: { label: 'Сборщик', desc: 'Выполняет монтаж.' },
|
||||||
{id:'ms', name:'Мария С.', short:'МС', color:'#0891B2', salon:'lenina', visits:35, deals:9, revenue:610000, conversion:26, avg:67800, rating:4.5, active:true},
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
{
|
|
||||||
salonId:'pobedy', salon:'Салон Победы', color:'#8B5CF6',
|
// 3.2 orderStatus
|
||||||
admin:{name:'Ирина С.', short:'ИС', userId:'admin_pobedy'},
|
orderStatus: {
|
||||||
orders:20, ordersPlan:22, revenue:1310000, revenuePlan:1500000,
|
lead: { label: 'Лид', color: '#94A3B8' },
|
||||||
overdue:1, overdueRisk:113000, purchases:1, newLeads:12, status:'warn',
|
measuring: { label: 'Замер', color: '#3B82F6' },
|
||||||
managers:[
|
project: { label: 'Проект', color: '#8B5CF6' },
|
||||||
{id:'pv', name:'Пётр В.', short:'ПВ', color:'#059669', salon:'pobedy', visits:28, deals:7, revenue:490000, conversion:25, avg:70000, rating:4.3, active:true},
|
tech: { label: 'Техника', color: '#0891B2' },
|
||||||
{id:'iv', name:'Иван В.', short:'ИВ', color:'#D97706', salon:'pobedy', visits:31, deals:8, revenue:530000, conversion:26, avg:66250, rating:4.1, active:false},
|
technolog: { label: 'Технолог', color: '#D97706' },
|
||||||
]
|
production: { label: 'Производство', color: '#7C3AED' },
|
||||||
|
assembly: { label: 'Сборка', color: '#059669' },
|
||||||
|
done: { label: 'Закрыт', color: '#16A34A' },
|
||||||
|
canceled: { label: 'Отменён', color: '#DC2626' },
|
||||||
},
|
},
|
||||||
];
|
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════
|
// 3.3 paymentStatus
|
||||||
// ПЛОСКИЙ СПИСОК ВСЕХ МЕНЕДЖЕРОВ (производный от _HIERARCHY)
|
paymentStatus: {
|
||||||
// ═══════════════════════════════════════════════════════════════════
|
none: { label: 'Без оплаты', color: '#94A3B8' },
|
||||||
var _CHESS_MGRS = (function(){
|
partial: { label: 'Частичная', color: '#D97706' },
|
||||||
var list = [];
|
paid: { label: 'Оплачен', color: '#16A34A' },
|
||||||
_HIERARCHY.forEach(function(h){
|
refunded: { label: 'Возврат', color: '#DC2626' },
|
||||||
h.managers.forEach(function(m){ list.push(m); });
|
},
|
||||||
});
|
|
||||||
return list;
|
|
||||||
})();
|
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════
|
// 3.4 clientSource
|
||||||
// ШАХМАТКА: временные слоты и расписание
|
clientSource: {
|
||||||
// ═══════════════════════════════════════════════════════════════════
|
walkin: { label: 'Зашёл в салон' },
|
||||||
var _CHESS_HOURS = ['10:00','11:00','12:00','13:00','14:00','15:00','16:00','17:00','18:00','19:00','20:00'];
|
instagram: { label: 'Instagram' },
|
||||||
|
avito: { label: 'Avito' },
|
||||||
|
referral: { label: 'Рекомендация' },
|
||||||
|
site: { label: 'Сайт' },
|
||||||
|
other: { label: 'Другое' },
|
||||||
|
},
|
||||||
|
|
||||||
var _CHESS_DATA = {
|
// 3.5 clientStatus
|
||||||
'ak':{'10:00':{client:'Орлова М.', type:'consult', status:'done'},
|
clientStatus: {
|
||||||
'11:00':{client:'Соколов А.', type:'follow', status:'done'},
|
lead: { label: 'Лид', color: '#94A3B8' },
|
||||||
'12:00':{client:'', type:'', status:'free'},
|
active: { label: 'Активный', color: '#3B82F6' },
|
||||||
'13:00':{client:'', type:'', status:'free'},
|
repeat: { label: 'Повторный', color: '#16A34A' },
|
||||||
'14:00':{client:'Ким Л.', type:'consult', status:'busy'},
|
lost: { label: 'Потерян', color: '#DC2626' },
|
||||||
'15:00':{client:'Ким Л.', type:'consult', status:'busy'},
|
},
|
||||||
'16:00':{client:'Захаров П.', type:'measure', status:'busy'},
|
|
||||||
'17:00':{client:'Новикова С.', type:'consult', status:'free'},
|
// 3.6 appointmentType
|
||||||
'18:00':{client:'', type:'', status:'free'},
|
appointmentType: {
|
||||||
'19:00':{client:'Громов И.', type:'follow', status:'free'},
|
consult: { label: 'Консультация', color: '#3B82F6' },
|
||||||
'20:00':{client:'', type:'', status:'free'}},
|
follow: { label: 'Повторный контакт', color: '#0891B2' },
|
||||||
'ms':{'10:00':{client:'Козлов Р.', type:'follow', status:'done'},
|
measure: { label: 'Замер', color: '#D97706' },
|
||||||
'11:00':{client:'', type:'', status:'free'},
|
tech: { label: 'Техническая', color: '#8B5CF6' },
|
||||||
'12:00':{client:'Лебедев С.', type:'consult', status:'done'},
|
},
|
||||||
'13:00':{client:'', type:'', status:'free'},
|
|
||||||
'14:00':{client:'Петрова А.', type:'tech', status:'busy'},
|
// 3.7 appointmentStatus (нет 'free' — свобода = отсутствие записи)
|
||||||
'15:00':{client:'Петрова А.', type:'tech', status:'busy'},
|
appointmentStatus: {
|
||||||
'16:00':{client:'', type:'', status:'free'},
|
busy: { label: 'Занято', color: '#3B82F6' },
|
||||||
'17:00':{client:'Морозов В.', type:'consult', status:'busy'},
|
done: { label: 'Завершено', color: '#16A34A' },
|
||||||
'18:00':{client:'', type:'', status:'free'},
|
noshow: { label: 'Не пришёл', color: '#DC2626' },
|
||||||
'19:00':{client:'', type:'', status:'free'},
|
canceled: { label: 'Отменено', color: '#94A3B8' },
|
||||||
'20:00':{client:'', type:'', status:'free'}},
|
},
|
||||||
'pv':{'10:00':{client:'', type:'', status:'free'},
|
|
||||||
'11:00':{client:'Сидорова Н.', type:'consult', status:'done'},
|
// 3.8 requestType
|
||||||
'12:00':{client:'Фёдоров К.', type:'follow', status:'noshow'},
|
requestType: {
|
||||||
'13:00':{client:'', type:'', status:'free'},
|
supply: { label: 'Снабжение' },
|
||||||
'14:00':{client:'', type:'', status:'free'},
|
escalate: { label: 'Эскалация' },
|
||||||
'15:00':{client:'Баринова Т.', type:'consult', status:'busy'},
|
visit: { label: 'Выезд/транспорт' },
|
||||||
'16:00':{client:'Баринова Т.', type:'consult', status:'busy'},
|
schedule: { label: 'Расписание' },
|
||||||
'17:00':{client:'', type:'', status:'free'},
|
other: { label: 'Другое' },
|
||||||
'18:00':{client:'Яковлев М.', type:'measure', status:'busy'},
|
},
|
||||||
'19:00':{client:'Яковлев М.', type:'measure', status:'busy'},
|
|
||||||
'20:00':{client:'', type:'', status:'free'}},
|
// 3.9 priority
|
||||||
'iv':{'10:00':{client:'Тихонова Р.', type:'consult', status:'done'},
|
priority: {
|
||||||
'11:00':{client:'Тихонова Р.', type:'consult', status:'done'},
|
low: { label: 'Низкий', color: '#94A3B8' },
|
||||||
'12:00':{client:'', type:'', status:'free'},
|
normal: { label: 'Обычный', color: '#3B82F6' },
|
||||||
'13:00':{client:'Волков Е.', type:'follow', status:'done'},
|
high: { label: 'Высокий', color: '#DC2626' },
|
||||||
'14:00':{client:'', type:'', status:'free'},
|
},
|
||||||
'15:00':{client:'Кузьмин О.', type:'consult', status:'busy'},
|
|
||||||
'16:00':{client:'', type:'', status:'free'},
|
// 3.10 requestStatus
|
||||||
'17:00':{client:'Рыбаков Д.', type:'tech', status:'free'},
|
requestStatus: {
|
||||||
'18:00':{client:'Рыбаков Д.', type:'tech', status:'free'},
|
new: { label: 'Новая', color: '#3B82F6' },
|
||||||
'19:00':{client:'', type:'', status:'free'},
|
inWork: { label: 'В работе', color: '#D97706' },
|
||||||
'20:00':{client:'', type:'', status:'free'}},
|
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 _MONTHLY_STATS = [
|
var config = {
|
||||||
{id:'ak', name:'Анна К.', color:'#7C3AED', salon:'lenina', months:{
|
kpiNorm: 80,
|
||||||
'Май':{visits:42, deals:14, revenue:847000, conversion:33, avg:60500, rating:4.8},
|
currency: 'RUB',
|
||||||
'Апр':{visits:38, deals:11, revenue:712000, conversion:29, avg:64700, rating:4.7},
|
timezone: 'Europe/Moscow',
|
||||||
'Мар':{visits:45, deals:16, revenue:920000, conversion:36, avg:57500, rating:4.9},
|
workdayStart: '10:00',
|
||||||
}},
|
workdayEnd: '20:00',
|
||||||
{id:'ms', name:'Мария С.', color:'#0891B2', salon:'lenina', months:{
|
slotMinutes: 60,
|
||||||
'Май':{visits:35, deals:9, revenue:610000, conversion:26, avg:67800, rating:4.5},
|
commissionRules: [
|
||||||
'Апр':{visits:41, deals:13, revenue:780000, conversion:32, avg:60000, rating:4.6},
|
{ role: 'manager', rate: 0.07 },
|
||||||
'Мар':{visits:39, deals:12, revenue:695000, conversion:31, avg:57900, rating:4.4},
|
],
|
||||||
}},
|
};
|
||||||
{id:'pv', name:'Пётр В.', color:'#059669', salon:'pobedy', months:{
|
|
||||||
'Май':{visits:28, deals:7, revenue:490000, conversion:25, avg:70000, rating:4.3},
|
// ═══════════════════════════════════════════════════════════════════
|
||||||
'Апр':{visits:32, deals:9, revenue:540000, conversion:28, avg:60000, rating:4.2},
|
// 1.1 salons — салоны сети
|
||||||
'Мар':{visits:30, deals:8, revenue:520000, conversion:27, avg:65000, rating:4.4},
|
// ═══════════════════════════════════════════════════════════════════
|
||||||
}},
|
var salons = [
|
||||||
{id:'iv', name:'Иван В.', color:'#D97706', salon:'pobedy', months:{
|
{ id: 'sal_lenina', name: 'Салон на Ленина', address: 'пр. Ленина, 42',
|
||||||
'Май':{visits:31, deals:8, revenue:530000, conversion:26, avg:66250, rating:4.1},
|
color: '#3B82F6', adminId: 'usr_adm_lenina', revenuePlan: 1700000, ordersPlan: 30, active: true },
|
||||||
'Апр':{visits:29, deals:7, revenue:480000, conversion:24, avg:68600, rating:4.0},
|
{ id: 'sal_pobedy', name: 'Салон на Победы', address: 'пр. Победы, 12',
|
||||||
'Мар':{visits:33, deals:10, revenue:610000, conversion:30, avg:61000, rating:4.3},
|
color: '#8B5CF6', adminId: 'usr_adm_pobedy', revenuePlan: 1500000, ordersPlan: 22, active: true },
|
||||||
}},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════
|
||||||
// ЗАЯВКИ МЕНЕДЖЕРОВ → АДМИНИСТРАТОР (+ эскалация → КД)
|
// 1.2 users — сотрудники (все роли). KPI здесь НЕ хранятся (БП-7).
|
||||||
// ═══════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════
|
||||||
window._MGR_REQUESTS = window._MGR_REQUESTS || [
|
var users = [
|
||||||
{id:'r1', mgr:'Анна К.', mgrId:'ak', salon:'lenina', color:'#7C3AED', type:'supply', prio:'high', title:'Закончились образцы ткани', body:'Нет образцов искусственной замши — теряем клиентов. Нужно срочно дозаказать у Мебельтекстиля.', created:'сегодня 09:14', status:'new'},
|
// КД (salonId = null, над салонами — БП-12)
|
||||||
{id:'r2', mgr:'Мария С.', mgrId:'ms', salon:'lenina', color:'#0891B2', type:'escalate', prio:'high', title:'Конфликт с клиентом Козлов Р.', body:'Клиент требует возврат 25 000 ₽ из-за задержки поставки. Говорит, что подаст жалобу. Прошу вмешаться.', created:'сегодня 10:30', status:'new'},
|
{ id: 'usr_owner', name: 'Васильев Руслан', initials: 'ВР', role: 'owner',
|
||||||
{id:'r3', mgr:'Пётр В.', mgrId:'pv', salon:'pobedy', color:'#059669', type:'visit', prio:'normal', title:'Нужна машина для выезда к клиенту', body:'Клиент Сидорова на Васильевском — хочет видеть образцы на дому. Нужен транспорт на 15:00.', created:'сегодня 11:05', status:'new'},
|
salonId: null, color: '#0F0F1A', phone: '+79110000001', active: true, commissionRate: null },
|
||||||
{id:'r4', mgr:'Анна К.', mgrId:'ak', salon:'lenina', color:'#7C3AED', type:'supply', prio:'normal', title:'Каталоги кухонь Hettich закончились', body:'Остался 1 экземпляр. Клиенты берут домой, не возвращают. Надо заказать 10 шт.', created:'вчера 17:22', status:'done'},
|
|
||||||
{id:'r5', mgr:'Мария С.', mgrId:'ms', salon:'lenina', color:'#0891B2', type:'schedule', prio:'normal', title:'Запрос на замену смены — 31 мая', body:'Прошу разрешить обменяться сменой с Петром: я работаю 31 мая вместо него, он — 1 июня вместо меня.', created:'вчера 14:10', status:'new'},
|
// Администраторы салонов
|
||||||
|
{ 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_*).
|
||||||
// ═══════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════
|
||||||
window._SHIFT_REQS = window._SHIFT_REQS || [
|
var clients = [
|
||||||
{id:'sr1', mgr:'Мария С.', mgrId:'ms', salon:'lenina', color:'#0891B2', type:'swap', dates:'31 мая ↔ 1 июня', with:'Пётр В.', status:'pending'},
|
// ── Из заказов ───────────────────────────────────────────────────
|
||||||
{id:'sr2', mgr:'Пётр В.', mgrId:'pv', salon:'pobedy', color:'#059669', type:'off', dates:'2 июня (отгул)', with:null, status:'pending'},
|
{ 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 = [];
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════
|
||||||
|
// ВНУТРЕННИЕ УТИЛИТЫ
|
||||||
// ═══════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
/** Найти запись _HIERARCHY по salonId */
|
/** Заказы менеджера, закрытые (done) за период (фильтр по месяцу closedAt||createdAt). */
|
||||||
function _getSalon(salonId){
|
function _ordersByMgr(userId) {
|
||||||
return _HIERARCHY.find(function(h){ return h.salonId===salonId; }) || null;
|
return orders.filter(function (o) { return o.managerId === userId; });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Найти менеджера по id из плоского списка */
|
/** Принадлежит ли ISO-момент периоду. period: 'Май'|'Апр'|'Мар' → 2026-05/04/03. null → любой. */
|
||||||
function _getMgr(mgrId){
|
function _inPeriod(iso, period) {
|
||||||
return _CHESS_MGRS.find(function(m){ return m.id===mgrId; }) || null;
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Получить KPI менеджера за период (из _MONTHLY_STATS) */
|
// Демо-числа для прошлых периодов (нет фактических данных по orders/appointments).
|
||||||
function _getMgrStats(mgrId, period){
|
var _DEMO_MGR_STATS = {
|
||||||
var stat = _MONTHLY_STATS.find(function(s){ return s.id===mgrId; });
|
'usr_001': { 'Апр': { visits: 38, deals: 11, revenue: 712000, conversion: 29, avg: 64727, kpi: 29 },
|
||||||
return stat ? (stat.months[period||'Май'] || {}) : {};
|
'Мар': { 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 || 'Май');
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user