'); win.document.close(); } async function showOrderHistory(woId){ var d=await api('/api/crm/work-orders/'+woId+'/history'); if(!d)return; var h=''; if(d.length){ d.forEach(function(log){ h+='
'; h+='
'; h+='
'+(SL[log.old_status]||'—')+' → '+SL[log.new_status]+'
'; h+='
'+(log.changed_by_name||'система')+'
'; h+='
'+new Date(log.changed_at).toLocaleString('ru')+'
'; h+='
'; }); } else h='
Нет записей
'; openModal('История изменений', h); } function uploadPhotoUI(woId){ var h='
'; h+='
Тип
'; h+='
'; h+='
'; openModal('Загрузка фото', h); } async function submitPhoto(woId){ var file=document.getElementById('up-file').files[0]; if(!file){toast('Выберите файл','error');return;} var fd=new FormData();fd.append('photo',file);fd.append('photo_type',document.getElementById('up-type').value); try{var r=await fetch('/api/crm/work-orders/'+woId+'/photos',{method:'POST',headers:{'Authorization':'Bearer '+TOKEN},body:fd});var d=await r.json();if(r.ok){toast('Фото загружено','success');closeModal();}else toast(d.error||'Ошибка','error');}catch(e){toast('Ошибка','error');} } async function exportCSV(){ try{ var r=await fetch('/api/crm/clients/export/csv',{headers:{'Authorization':'Bearer '+TOKEN}}); if(!r.ok){toast('Ошибка экспорта','error');return;} var blob=await r.blob(); var url=URL.createObjectURL(blob); var a=document.createElement('a');a.href=url;a.download='clients_stage-x.csv'; document.body.appendChild(a);a.click();a.remove();URL.revokeObjectURL(url); toast('CSV скачан','success'); }catch(e){toast('Ошибка','error');} } async function deleteSvc(woId,svcId){ if(!confirm('Удалить услугу?'))return; var r=await api('/api/crm/work-orders/'+woId+'/services/'+svcId,'DELETE'); if(r){toast('Услуга удалена','success');closeModal();openOrder(woId);} } async function deletePart(woId,partId){ if(!confirm('Удалить запчасть?'))return; var r=await api('/api/crm/work-orders/'+woId+'/parts/'+partId,'DELETE'); if(r){toast('Запчасть удалена','success');closeModal();openOrder(woId);} } function editMasterComment(woId,current){current=current||''; var h='
'; h+='
Комментарий мастера
'; h+='
'; openModal('Комментарий мастера', h); } async function submitMasterComment(woId){ var text=document.getElementById('mc-text').value; var r=await api('/api/crm/work-orders/'+woId+'/comment','PATCH',{master_comment:text}); if(r){toast('Комментарий сохранён','success');closeModal();openOrder(woId);} } // ===== KANBAN DRAG & DROP ===== var kbDragWoId=null; function kbDragStart(e,woId){kbDragWoId=woId;e.target.classList.add('dragging');e.dataTransfer.effectAllowed='move';} function kbDragEnd(e){e.target.classList.remove('dragging');document.querySelectorAll('.kb-col').forEach(function(c){c.classList.remove('drag-over')});} function kbDragOver(e){e.preventDefault();e.currentTarget.classList.add('drag-over');} function kbDragLeave(e){e.currentTarget.classList.remove('drag-over');} async function kbDrop(e,newStatus){ e.preventDefault(); e.currentTarget.classList.remove('drag-over'); if(!kbDragWoId)return; var r=await api('/api/crm/work-orders/'+kbDragWoId+'/status','PATCH',{status:newStatus}); if(r){toast('Статус: '+SL[newStatus],'success');loadKanban();loadBadges(); var td=new Date();document.getElementById('topbar-date').textContent=td.toLocaleDateString('ru-RU',{weekday:'short',day:'numeric',month:'short'});} kbDragWoId=null; } // ===== KEYBOARD SHORTCUTS ===== document.addEventListener('keydown', function(e){ // Ctrl+K or / to focus search if((e.ctrlKey && e.key==='k') || (e.key==='/' && document.activeElement.tagName!=='INPUT' && document.activeElement.tagName!=='TEXTAREA')){ e.preventDefault(); var s=document.getElementById('global-search'); if(s){s.focus();s.select();} } // Escape to close modal if(e.key==='Escape'){ closeModal(); document.getElementById('global-results').style.display='none'; } }); // ===== DUPLICATE ORDER ===== async function duplicateOrder(woId){ var wo=await api('/api/crm/work-orders/'+woId); if(!wo)return; if(!confirm('Создать копию заказ-наряда '+wo.order_number+'?'))return; var data={client_id:wo.client_id,car_id:wo.car_id,mileage:wo.mileage_at_reception,client_comment:'Копия '+wo.order_number}; var r=await api('/api/crm/work-orders','POST',data); if(!r)return; // Copy services for(var i=0;i0&&b.type!=='spend'&&b.type!=='welcome_expired'; h+='
'; h+='
'; h+='
'+(types[b.type]||b.type)+(b.description?' — '+esc(b.description):'')+'
'; h+='
'+(isPositive?'+':'')+b.amount+' б.
'; h+='
'+new Date(b.created_at).toLocaleDateString('ru')+'
'; h+='
'; }); } else h='
Нет операций
'; openModal('История бонусов', h); } // ===== CLIENT AUTOCOMPLETE IN ORDER FORM ===== var nwoSearchTimer; async function nwoSearchClient(){ clearTimeout(nwoSearchTimer); var q=document.getElementById('nwo-client-search').value; var el=document.getElementById('nwo-client-results'); if(!q||q.length<2){el.style.display='none';return;} nwoSearchTimer=setTimeout(async function(){ var d=await api('/api/crm/clients/quick?q='+encodeURIComponent(q)); if(!d||!d.length){el.style.display='none';return;} var h=''; d.forEach(function(c){ h+='
'; h+=''+esc(c.name||'Без имени')+' '+esc(c.phone); if(c.cars)h+='
'+esc(c.cars)+'
'; h+='
'; }); el.innerHTML=h;el.style.display='block'; },250); } function nwoSelectClient(id,label){ document.getElementById('nwo-client').value=id; document.getElementById('nwo-client-search').value=label; document.getElementById('nwo-client-results').style.display='none'; nwoLoadCars(); } async function loadClientOrders(clientId){ woView='all'; document.querySelectorAll('#wo-filters .filter-btn').forEach(function(b){b.classList.remove('active')}); var d=await api('/api/crm/work-orders?limit=50&client_id='+clientId); if(!d)return; var el=document.getElementById('wo-list'); document.getElementById('wo-kanban').style.display='none'; el.style.display='block'; var h='
Заказы клиента ('+d.total+')
'; d.items.forEach(function(wo){ h+='
'; h+='
'+esc(wo.order_number)+' '+SL[wo.status]+'
'+(wo.total_final||0).toLocaleString('ru')+' ₽
'; h+='
'+(wo.car_brand?wo.car_brand+' '+wo.car_model:'')+(wo.scheduled_date?' · '+wo.scheduled_date.slice(0,10):'')+'
'; h+='
'; }); if(!d.items.length)h+='
Нет заказов
'; el.innerHTML=h; } // ===== ROLE-BASED NAV ===== function applyRoleNav(){ if(!USER)return; var hiddenForNonAdmin=['finance','marketing','settings']; if(USER.role!=='admin'&&USER.role!=='receptionist'){ document.querySelectorAll('.nav-item').forEach(function(n){ hiddenForNonAdmin.forEach(function(page){ if(n.onclick&&n.onclick.toString().indexOf(page)!==-1)n.style.display='none'; }); }); } } async function deleteRec(woId,recId){ if(!confirm('Удалить рекомендацию?'))return; var r=await api('/api/crm/work-orders/'+woId+'/recommendations/'+recId,'DELETE'); if(r){toast('Удалено','success');closeModal();openOrder(woId);} } async function editOrderForm(woId){ var wo=await api('/api/crm/work-orders/'+woId);if(!wo)return; if(wo.status==='closed'||wo.status==='cancelled'){toast('Нельзя редактировать закрытый заказ','error');return;} var h='
'; h+='
'+esc(wo.order_number)+'
'; h+='
Дата
Время
'; h+='
Скидка %
Скидка ₽
'; h+='
Пробег (км)
'; h+='
'; openModal('Редактировать заказ', h); } async function submitEditOrder(woId){ var data={ scheduled_date:document.getElementById('eo-date').value||null, scheduled_time:document.getElementById('eo-time').value||null, discount_percent:parseFloat(document.getElementById('eo-disc-pct').value)||0, discount_fixed:parseInt(document.getElementById('eo-disc-fix').value)||0, mileage_at_reception:parseInt(document.getElementById('eo-mileage').value)||null }; var r=await api('/api/crm/work-orders/'+woId,'PUT',data); if(r){toast('Заказ обновлён','success');closeModal();openOrder(woId);} } async function exportOrdersCSV(){ try{ var r=await fetch('/api/crm/work-orders/export/csv',{headers:{'Authorization':'Bearer '+TOKEN}}); if(!r.ok){toast('Ошибка','error');return;} var blob=await r.blob();var url=URL.createObjectURL(blob); var a=document.createElement('a');a.href=url;a.download='orders_stage-x.csv'; document.body.appendChild(a);a.click();a.remove();URL.revokeObjectURL(url); toast('CSV скачан','success'); }catch(e){toast('Ошибка','error');} } async function openItem(itemId){ var i=await api('/api/crm/inventory/'+itemId);if(!i)return; var mv=await api('/api/crm/inventory/'+itemId+'/movements'); var cats={oils:'Масла',filters:'Фильтры',ppf_film:'PPF',tint_film:'Тонировка',ceramic:'Керамика',polish_chem:'Полировка',exhaust:'Выхлоп',fasteners:'Крепёж',other:'Прочее'}; var h='
'; h+='
'; h+='
'+(i.photo_url?'':'
📦
')+'
'; h+='
Артикул: '+(i.article||'—')+'
'; h+='
Категория: '+(cats[i.category]||i.category||'—')+'
'; h+='
Ед. изм.: '+(i.unit||'шт')+'
'; h+='
Место: '+(i.storage_location||'—')+'
'; h+='
Мин. остаток: '+parseFloat(i.min_stock)+'
'; h+='
Остаток: '+parseFloat(i.current_stock)+' '+esc(i.unit)+'
'; h+='
Закупка: '+(i.cost_price||0).toLocaleString('ru')+' ₽ · Продажа: '+(i.sell_price||0).toLocaleString('ru')+' ₽
'; h+='
'; h+='
'; h+='
Движения
'; if(mv&&mv.length){ mv.forEach(function(m){ var isIn=m.type==='in'; h+='
'; h+='
'; h+=''+(isIn?'Приход':'Расход')+' '+parseFloat(m.quantity)+''+(m.notes?' · '+esc(m.notes):'')+''; h+=''+new Date(m.created_at).toLocaleDateString('ru')+''; h+='
'; }); } else h+='
Нет движений
'; h+='
'; openModal(esc(i.name), h); } async function editItem(itemId){ var i=await api('/api/crm/inventory/'+itemId);if(!i)return; var h='
'; h+='
Название
'; h+='
Цена закупки ₽
Цена продажи ₽
'; h+='
Мин. остаток
Место
'; h+='
'; openModal('Редактировать: '+esc(i.name), h); } async function submitEditItem(itemId){ var r=await api('/api/crm/inventory/'+itemId,'PUT',{ name:document.getElementById('ei-name').value, cost_price:parseInt(document.getElementById('ei-cost').value)||0, sell_price:parseInt(document.getElementById('ei-sell').value)||0, min_stock:parseFloat(document.getElementById('ei-min').value)||0, storage_location:document.getElementById('ei-loc').value||null }); if(r){toast('Товар обновлён','success');closeModal();loadInventory();} } function uploadItemPhoto(itemId){ var h='
'; h+='
Фото товара
'; h+='
'; openModal('Фото товара', h); } async function submitItemPhoto(itemId){ var file=document.getElementById('ip-file').files[0]; if(!file){toast('Выберите фото','error');return;} var fd=new FormData();fd.append('photo',file); try{ var r=await fetch('/api/crm/inventory/'+itemId+'/photo',{method:'POST',headers:{'Authorization':'Bearer '+TOKEN},body:fd}); var d=await r.json(); if(r.ok){toast('Фото загружено','success');closeModal();loadInventory();} else toast(d.error||'Ошибка','error'); }catch(e){toast('Ошибка загрузки','error');} } async function exportInvCSV(){ try{ var r=await fetch('/api/crm/inventory/export/csv',{headers:{'Authorization':'Bearer '+TOKEN}}); if(!r.ok){toast('Ошибка','error');return;} var blob=await r.blob();var url=URL.createObjectURL(blob); var a=document.createElement('a');a.href=url;a.download='inventory_stage-x.csv'; document.body.appendChild(a);a.click();a.remove();URL.revokeObjectURL(url); toast('CSV скачан','success'); }catch(e){toast('Ошибка','error');} } function openFullPhoto(src){ var overlay=document.createElement('div'); overlay.style.cssText='position:fixed;inset:0;z-index:99999;background:rgba(0,0,0,.85);display:flex;align-items:center;justify-content:center;cursor:zoom-out'; overlay.innerHTML=''; overlay.addEventListener('click',function(){overlay.remove()}); document.body.appendChild(overlay); } // ===== DYNAMIC CATEGORIES ===== var invCategories=[]; async function loadInvCategories(){ var d=await api('/api/crm/inventory/categories'); if(!d)return; invCategories=d; // Update filter buttons var el=document.getElementById('inv-cat-filters'); if(!el)return; el.innerHTML=''; d.forEach(function(c){ el.innerHTML+=''; }); } function getCatOptions(){ var opts=''; invCategories.forEach(function(c){opts+='';}); if(!opts)opts=''; return opts; } function manageCats(){ var h='
'; h+='
Категории склада
'; h+='
'; invCategories.forEach(function(c){ h+='
'+esc(c.name)+''+esc(c.slug)+'
'; }); h+='
'; h+='
'; h+='
'; openModal('Управление категориями', h); } async function addCat(){ var name=document.getElementById('new-cat-name').value; if(!name){toast('Введите название','error');return;} var r=await api('/api/crm/inventory/categories','POST',{name:name}); if(r){toast('Категория добавлена','success');closeModal();await loadInvCategories();manageCats();} } async function deleteCat(id){ if(!confirm('Удалить категорию?'))return; var r=await api('/api/crm/inventory/categories/'+id,'DELETE'); if(r){toast('Удалено','success');closeModal();await loadInvCategories();manageCats();} } var woSearchTimer; function searchOrders(){clearTimeout(woSearchTimer);woSearchTimer=setTimeout(function(){loadOrders()},300)} async function editEmployee(empId){ var d=await api('/api/crm/employees');if(!d)return; var e=d.find(function(x){return x.id===empId}); if(!e)return; var roles={admin:'Администратор',receptionist:'Мастер-приёмщик',mechanic:'Механик',washer:'Мойщик'}; var h='
'; h+='
ФИО
'; h+='
Роль
Телефон
'; h+='
Telegram ID (для уведомлений)
'; h+='
Оклад ₽
Комиссия %
'; h+='
'; loadEmpStats(empId); // Load roles for select var rolesData=await api('/api/crm/roles'); if(rolesData){var sel=document.getElementById('ee-role');rolesData.forEach(function(r){sel.innerHTML+='';});} h+='
'+(e.is_active?'':'')+'
'; h+='
'; openModal('Сотрудник: '+esc(e.name), h); } async function submitEditEmp(empId){ var r=await api('/api/crm/employees/'+empId,'PUT',{ name:document.getElementById('ee-name').value, role:document.getElementById('ee-role').value, phone:document.getElementById('ee-phone').value||null, telegram_id:document.getElementById('ee-tg').value||null, salary_base:parseInt(document.getElementById('ee-salary').value)||0, commission_default:parseFloat(document.getElementById('ee-comm').value)||0 }); if(r){toast('Сохранено','success');closeModal();loadEmployees();} } async function toggleEmpActive(empId,active){ if(!confirm(active?'Вернуть сотрудника?':'Уволить сотрудника?'))return; var r=await api('/api/crm/employees/'+empId,'PUT',{is_active:active,fired_at:active?null:new Date().toISOString().slice(0,10)}); if(r){toast(active?'Сотрудник восстановлен':'Сотрудник уволен','success');closeModal();loadEmployees();} } async function loadEmpStats(empId){ var d=await api('/api/crm/employees/'+empId+'/stats');if(!d)return; var el=document.getElementById('emp-stats-'+empId);if(!el)return; el.innerHTML='
Статистика за месяц
Заказов: '+d.orders_count+'
Услуг: '+d.services_count+'
Выручка: '+d.services_revenue.toLocaleString("ru")+' ₽
Комиссия '+d.commission_rate+'%: '+d.commission.toLocaleString("ru")+' ₽
Итого: '+(d.total_pay||0).toLocaleString("ru")+' ₽
'; } async function exportFinCSV(){ var from=document.getElementById('fin-from').value; var to=document.getElementById('fin-to').value; try{ var url='/api/crm/finance/export/csv'+(from?'?from='+from+'&to='+(to||''):''); var r=await fetch(url,{headers:{'Authorization':'Bearer '+TOKEN}}); if(!r.ok){toast('Ошибка','error');return;} var blob=await r.blob();var u=URL.createObjectURL(blob); var a=document.createElement('a');a.href=u;a.download='finance_stage-x.csv'; document.body.appendChild(a);a.click();a.remove();URL.revokeObjectURL(u); toast('CSV скачан','success'); }catch(e){toast('Ошибка','error');} } async function submitIncome(){ var amount=parseInt(document.getElementById('ni-amount').value); if(!amount){toast('Укажите сумму','error');return;} var r=await api('/api/crm/finance/transactions','POST',{ type:'income', amount:amount, description:document.getElementById('ni-desc').value||null, payment_method:document.getElementById('ni-method').value }); if(r){toast('Доход добавлен','success');closeModal();loadFinance();} } // ===== CALENDAR ===== var calYear=new Date().getFullYear(); var calMonth=new Date().getMonth()+1; var calData={}; var calSelectedDate=null; function calNav(dir){calMonth+=dir;if(calMonth>12){calMonth=1;calYear++;}if(calMonth<1){calMonth=12;calYear--;}loadCalendar();} function calToday(){calYear=new Date().getFullYear();calMonth=new Date().getMonth()+1;loadCalendar();} async function loadCalendar(){ var d=await api('/api/crm/calendar?year='+calYear+'&month='+calMonth); if(!d)return; calData=d.orders||{}; var months=['','Январь','Февраль','Март','Апрель','Май','Июнь','Июль','Август','Сентябрь','Октябрь','Ноябрь','Декабрь']; document.getElementById('cal-title').textContent=months[calMonth]+' '+calYear; renderCalGrid(); } function renderCalGrid(){ var firstDate=new Date(calYear,calMonth-1,1); var lastDate=new Date(calYear,calMonth,0); var startDay=firstDate.getDay()||7; // Monday=1 var daysInMonth=lastDate.getDate(); var prevMonthDays=new Date(calYear,calMonth-1,0).getDate(); var today=new Date().toISOString().slice(0,10); var h='
'; var dayNames=['Пн','Вт','Ср','Чт','Пт','Сб','Вс']; dayNames.forEach(function(n){h+='
'+n+'
';}); // Previous month fill for(var i=startDay-2;i>=0;i--){ var d=prevMonthDays-i; h+='
'+d+'
'; } // Current month days for(var day=1;day<=daysInMonth;day++){ var dateStr=calYear+'-'+String(calMonth).padStart(2,'0')+'-'+String(day).padStart(2,'0'); var isToday=dateStr===today; var isSelected=dateStr===calSelectedDate; var events=calData[dateStr]||[]; h+='
'; h+='
'+day+'
'; events.slice(0,3).forEach(function(ev){ h+='
'; h+=(ev.scheduled_time?ev.scheduled_time.slice(0,5)+' ':'')+esc(ev.client_name||ev.order_number); h+='
'; }); if(events.length>3)h+='
+'+(events.length-3)+' ещё
'; h+='
'; } // Next month fill var totalCells=(startDay-1)+daysInMonth; var remaining=totalCells%7===0?0:7-(totalCells%7); for(var i=1;i<=remaining;i++){ h+='
'+i+'
'; } h+='
'; document.getElementById('cal-grid').innerHTML=h; // Show selected day detail if(calSelectedDate)showDayDetail(calSelectedDate); } function calSelectDay(dateStr){ calSelectedDate=dateStr; renderCalGrid(); } function showDayDetail(dateStr){ var events=calData[dateStr]||[]; var dayLabel=new Date(dateStr+'T12:00:00').toLocaleDateString('ru-RU',{weekday:'long',day:'numeric',month:'long'}); var h='
'; h+='
'+dayLabel+' ('+events.length+' записей)
'; h+=''; h+='
'; if(events.length){ events.forEach(function(ev){ h+='
'; h+='
'+(ev.scheduled_time?ev.scheduled_time.slice(0,5):'—')+'
'; h+='
'+esc(ev.client_name||'—')+'
'; h+='
'+(ev.car_brand?ev.car_brand+' '+ev.car_model:'')+(ev.plate_number?' ['+ev.plate_number+']':'')+'
'; h+=''+SL[ev.status]+''; h+='
'+(ev.total_final||0).toLocaleString('ru')+' ₽
'; h+='
'; }); } else { h+='
Нет записей на этот день
'; } document.getElementById('cal-day-detail').innerHTML=h; } function openNewOrderOnDate(dateStr){ var dayLabel=new Date(dateStr+'T12:00:00').toLocaleDateString('ru-RU',{weekday:'long',day:'numeric',month:'long'}); var h='
'; h+='
📅 '+dayLabel+'
'; h+='
Клиент *
'; h+='
Время *
Автомобиль
'; h+='
'; h+='
+ Новый автомобиль
'; h+='
Марка *
Модель *
'; h+='
Год выпуска *
Объём двигателя
Тип двигателя
'; h+='
Госномер
VIN
'; h+='
'; h+='
Услуга
'; h+='
Комментарий
'; h+='
'; bkServicesList=[]; openModal('Новая запись', h); } // ===== BOOKING FORM ===== var bkSearchTimer; async function bkSearchClient(){ clearTimeout(bkSearchTimer); var q=document.getElementById('bk-client-search').value; var el=document.getElementById('bk-client-results'); if(!q||q.length<2){el.style.display='none';return;} bkSearchTimer=setTimeout(async function(){ var d=await api('/api/crm/clients/quick?q='+encodeURIComponent(q)); if(!d||!d.length){el.style.display='none';return;} var h=''; d.forEach(function(c){ h+='
'; h+=''+esc(c.name||'')+' '+esc(c.phone); if(c.cars)h+='
'+esc(c.cars)+'
'; h+='
'; }); el.innerHTML=h;el.style.display='block'; },200); } function bkSelectClient(id,label){ document.getElementById('bk-client').value=id; document.getElementById('bk-client-search').value=label; document.getElementById('bk-client-results').style.display='none'; bkLoadCars(id); } async function bkLoadCars(clientId){ var sel=document.getElementById('bk-car'); sel.innerHTML=''; var cars=await api('/api/crm/clients/'+clientId+'/cars'); if(cars&&cars.length){ cars.forEach(function(c){sel.innerHTML+='';}); if(cars.length===1){sel.selectedIndex=1;document.getElementById('bk-new-car').style.display='none';} else{document.getElementById('bk-new-car').style.display='none';} } else { document.getElementById('bk-new-car').style.display='block'; } } async function submitBooking(dateStr){ var clientId=document.getElementById('bk-client').value; var clientSearch=document.getElementById('bk-client-search').value; // If no client selected but name/phone entered — create new client if(!clientId&&clientSearch){ var isPhone=clientSearch.match(/^[+\d\s()-]{7,}/); var newClient=await api('/api/crm/clients','POST',{name:isPhone?'':clientSearch,phone:isPhone?clientSearch.replace(/[^\d+]/g,''):'',source:'crm_calendar'}); if(!newClient){toast('Ошибка создания клиента','error');return;} clientId=newClient.id; toast('Клиент создан','success'); } if(!clientId){toast('Укажите клиента','error');return;} var time=document.getElementById('bk-time').value; var carId=document.getElementById('bk-car').value; var service=document.getElementById('bk-service').value; var comment=document.getElementById('bk-comment').value; // Create new car if needed if(!carId){ var brand=document.getElementById('bk-brand')?.value; var model=document.getElementById('bk-model')?.value; if(!brand||!model){toast('Укажите марку и модель авто','error');return;} if(brand==='custom'){brand=document.getElementById('bk-brand-custom')?.value||'';if(!brand){toast('Введите марку','error');return;}} var year=document.getElementById('bk-year')?.value; var carData={brand:brand,model:model,year:parseInt(year)||null,engine_volume:document.getElementById('bk-engine-vol')?.value||null,engine_type:document.getElementById('bk-engine-type')?.value||null,plate_number:document.getElementById('bk-plate')?.value?.toUpperCase()||null,vin:document.getElementById('bk-vin')?.value?.toUpperCase()||null}; var newCar=await api('/api/crm/clients/'+clientId+'/cars','POST',carData); if(!newCar){toast('Ошибка создания авто','error');return;} carId=newCar.id; toast('Авто '+brand+' '+model+' добавлено','success'); } // Create work order var data={client_id:parseInt(clientId), scheduled_date:dateStr, scheduled_time:time}; if(carId)data.car_id=parseInt(carId); if(comment)data.client_comment=comment; var r=await api('/api/crm/work-orders','POST',data); if(!r)return; // Add services for(var si=0;si'; }); el.innerHTML=h; } // ===== APP USERS ===== async function loadAppUsers(){ var stats=await api('/api/crm/app-users/stats'); if(stats){ document.getElementById('app-users-stats').innerHTML= '
'+ '
'+stats.total+'
Всего
'+ '
'+stats.active_week+'
За неделю
'+ '
'+stats.active_month+'
За месяц
'+ '
'+stats.blocked+'
Заблокировано
'; } var d=await api('/api/crm/app-users');if(!d)return; var h=''; d.forEach(function(u){ var lastSeen=u.last_seen_at?new Date(u.last_seen_at).toLocaleString('ru'):'—'; var regDate=u.registered_at?new Date(u.registered_at).toLocaleDateString('ru'):'—'; h+='
'; h+='
'+(u.telegram_first_name||'?')[0]+'
'; h+='
'+(u.telegram_first_name||'')+(u.telegram_last_name?' '+u.telegram_last_name:'')+'
'; h+='
'+(u.telegram_username?'@'+esc(u.telegram_username):'ID: '+u.telegram_id)+(u.phone?' · '+esc(u.phone):'')+'
'; h+='
Регистрация: '+regDate+'
'; h+='
Последний вход: '+lastSeen+'
'; if(u.client_name)h+='
Клиент: '+esc(u.client_name)+'
'; if(u.client_deleted)h+='
⚠ Клиент удалён
'; h+='
Баллы: '+(u.bonus_points||0)+' · Потрачено: '+(u.total_spent||0).toLocaleString('ru')+' ₽
'; h+='
'; }); if(!d.length)h='
Нет пользователей приложения
'; document.getElementById('app-users-list').innerHTML=h; } // ===== APP USER MATCH ON CLIENT CREATE ===== var ncMatchTimer; var ncMatchedClientId=null; async function ncCheckMatch(){ clearTimeout(ncMatchTimer); ncMatchedClientId=null; ncMatchTimer=setTimeout(async function(){ var phone=document.getElementById('nc-phone')?.value||''; var name=document.getElementById('nc-name')?.value||''; if(phone.length<7&&name.length<3)return; var url='/api/crm/match-app-user?phone='+encodeURIComponent(phone)+'&name='+encodeURIComponent(name); var d=await api(url); var banner=document.getElementById('nc-match-banner'); if(!banner)return; if(d&&d.matches&&d.matches.length){ var m=d.matches[0]; var h='
📱 Найден в приложении!
'; h+='
'+esc(m.client_name||m.telegram_first_name||'')+''; if(m.telegram_username)h+=' · @'+esc(m.telegram_username); h+='
'; if(m.client_phone)h+='
Тел: '+esc(m.client_phone)+'
'; if(m.bonus_points)h+='
Баллы: '+m.bonus_points+' · Визитов: '+(m.visit_count||0)+' · Потрачено: '+(m.total_spent||0).toLocaleString('ru')+' ₽
'; if(m.cars)h+='
Авто: '+esc(m.cars)+'
'; if(m.is_deleted)h+='
⚠ Клиент был удалён — будет восстановлен
'; h+=''; banner.innerHTML=h;banner.style.display='block'; ncMatchedClientId=m.client_id; } else { banner.style.display='none'; } },500); } async function ncLinkAppUser(clientId){ document.querySelectorAll("#global-search").forEach(function(el){el.value=""}); // Restore deleted client if needed await api('/api/crm/clients/'+clientId+'/restore','POST'); toast('Клиент привязан','success'); closeModal();openClient(clientId); } // ===== EDIT/DELETE CAR ===== async function editCarForm(clientId, carId){ _modalReturnTo=clientId; var cars=await api('/api/crm/clients/'+clientId+'/cars'); if(!cars)return; var car=cars.find(function(c){return c.id===carId}); if(!car)return; var h='
'; h+='
Марка *
Модель *
'; h+='
Год
Объём
Тип
'; h+='
Госномер
VIN
'; h+='
Пробег (км)
'; h+='
'; openModal('Редактировать авто', h); } async function submitEditCar(clientId, carId){ var data={ brand:document.getElementById('ecar-brand').value, model:document.getElementById('ecar-model').value, year:parseInt(document.getElementById('ecar-year').value)||null, engine_volume:document.getElementById('ecar-vol').value||null, engine_type:document.getElementById('ecar-type').value||null, plate_number:document.getElementById('ecar-plate').value||null, vin:document.getElementById('ecar-vin').value||null, current_mileage:parseInt(document.getElementById('ecar-mileage').value)||null }; if(!data.brand||!data.model){toast('Марка и модель обязательны','error');return;} var r=await api('/api/crm/clients/'+clientId+'/cars/'+carId,'PUT',data); if(r){toast('Авто обновлено','success');openClient(clientId);} } async function deleteCar(clientId, carId){ if(!confirm('Удалить автомобиль?'))return; var r=await api('/api/crm/clients/'+clientId+'/cars/'+carId,'DELETE'); if(r!==null){toast('Авто удалено','success');openClient(clientId);} } // ===== DOCUMENT GENERATION ===== async function genDoc(type, woId){ toast('Генерация документа...','success'); try { var r = await fetch('/api/crm/documents/generate', { method: 'POST', headers: {'Content-Type':'application/json','Authorization':'Bearer '+TOKEN}, body: JSON.stringify({type: type, work_order_id: woId}) }); if(!r.ok){toast('Ошибка генерации','error');return;} var blob = await r.blob(); var url = URL.createObjectURL(blob); window.open(url, '_blank'); } catch(e) { toast('Ошибка: '+e.message,'error'); } } // ===== SETTINGS TABS + DOC TEMPLATES ===== function setTab(tab,btn){ document.querySelectorAll('[id^="set-tab-"]').forEach(function(el){el.style.display='none';}); var el=document.getElementById('set-tab-'+tab);if(el)el.style.display='block'; if(btn){btn.parentElement.querySelectorAll('.filter-btn').forEach(function(b){b.classList.remove('active');});btn.classList.add('active');} if(tab==='documents')loadDocTemplates();if(tab==='changelog')loadChangelog();if(tab==='roadmap')loadRoadmap();if(tab==='reports')loadReports();if(tab==='roles')loadRoles(); } async function loadDocTemplates(){ var d=await api('/api/crm/doc-templates');if(!d)return; var h=''; d.forEach(function(t){ h+='
'; h+='
'+esc(t.name)+'
'; h+='
'; h+='
Компания: '+esc(t.company_name||'')+'
'; h+='
Адрес: '+esc(t.company_address||'')+'
'; h+='
Телефон: '+esc(t.company_phone||'')+'
'; if(t.company_inn)h+='
ИНН: '+esc(t.company_inn)+'
'; h+='
'; }); document.getElementById('set-tab-documents').innerHTML=h; } async function editDocTemplate(id){ var templates=await api('/api/crm/doc-templates');if(!templates)return; var t=templates.find(function(x){return x.id===id});if(!t)return; var h='
'; h+='
'+esc(t.name)+'
'; h+='
Название компании
'; h+='
Адрес
'; h+='
Телефон
ИНН
'; h+='
Подвал документа
'; if(t.type==='act')h+='
Текст акта
'; if(t.type==='warranty')h+='
Условия гарантии (каждая строка = пункт)
'; h+='
'; openModal('Шаблон: '+t.name, h); } async function saveDocTemplate(id,type){ var data={ company_name:document.getElementById('dt-company').value, company_address:document.getElementById('dt-address').value, company_phone:document.getElementById('dt-phone').value, company_inn:document.getElementById('dt-inn').value||null, footer_text:document.getElementById('dt-footer').value }; if(type==='act'){var el=document.getElementById('dt-act');if(el)data.act_text=el.value;} if(type==='warranty'){var el=document.getElementById('dt-warranty');if(el)data.warranty_terms=el.value;} var r=await api('/api/crm/doc-templates/'+id,'PUT',data); if(r){toast('Шаблон сохранён','success');closeModal();loadDocTemplates();} } // ===== CHANGELOG ===== async function loadChangelog(){ var d=await api('/api/crm/changelog');if(!d)return; var typeIcons={feature:'🆕',fix:'🔧',improvement:'⬆️',release:'🚀'}; var typeColors={feature:'rgba(76,175,80,.1)',fix:'rgba(255,152,0,.1)',improvement:'rgba(33,150,243,.1)',release:'rgba(201,31,31,.1)'}; var typeBorders={feature:'rgba(76,175,80,.3)',fix:'rgba(255,152,0,.3)',improvement:'rgba(33,150,243,.3)',release:'rgba(201,31,31,.3)'}; var h='
📝 История обновлений
'; d.forEach(function(c){ var icon=typeIcons[c.type]||'📌'; var bg=typeColors[c.type]||'rgba(136,136,136,.1)'; var border=typeBorders[c.type]||'rgba(136,136,136,.2)'; var date=c.date?new Date(c.date).toLocaleDateString('ru'):''; h+='
'; h+='
'; h+='
v'+esc(c.version)+' '+icon+' '+esc(c.title)+'
'; h+=''+date+'
'; var changes=c.changes.split('\n'); h+='
'; changes.forEach(function(line){if(line.trim())h+='
• '+esc(line.trim())+'
';}); h+='
'; }); if(!d.length)h+='
Нет записей
'; document.getElementById('set-tab-changelog').innerHTML=h; } function openNewChangelog(){ var h='
'; h+='
Версия *
Тип
'; h+='
Заголовок *
'; h+='
Изменения * (каждая строка = пункт)
'; h+='
'; openModal('Новое обновление', h); } async function submitChangelog(){ var version=document.getElementById('cl-version').value; var title=document.getElementById('cl-title').value; var changes=document.getElementById('cl-changes').value; var type=document.getElementById('cl-type').value; if(!version||!title||!changes){toast('Заполните все поля','error');return;} var r=await api('/api/crm/changelog','POST',{version:version,title:title,changes:changes,type:type}); if(r){toast('Обновление добавлено','success');closeModal();loadChangelog();} } // ===== ROADMAP ===== async function loadRoadmap(){ var d=await api('/api/crm/roadmap');if(!d)return; var catIcons={feature:'🆕',fix:'🔧',integration:'🔗',business:'💼',improvement:'⬆️'}; var statColors={planned:'var(--dim)',in_progress:'#FFB800',done:'var(--green)',cancelled:'var(--accent)'}; var statLabels={planned:'Планируется',in_progress:'В работе',done:'Готово',cancelled:'Отменено'}; var groups={}; d.forEach(function(r){var v=r.target_version||'Без версии';if(!groups[v])groups[v]=[];groups[v].push(r);}); var h='
🗺 План разработки
'; var versions=Object.keys(groups).sort(); versions.forEach(function(v){ var items=groups[v]; var doneCount=items.filter(function(i){return i.status==='done'}).length; var pct=items.length?Math.round(doneCount/items.length*100):0; h+='
'; h+='
v'+esc(v)+'
'+doneCount+'/'+items.length+' ('+pct+'%)
'; h+='
'; items.forEach(function(r){ var icon=catIcons[r.category]||'📌'; var stColor=statColors[r.status]||'var(--dim)'; var stLabel=statLabels[r.status]||r.status; h+='
'; h+='
'+icon+'
'; h+='
'+esc(r.description)+'
'; h+='
'; h+='
'; h+=''+stLabel+''; if(r.status!=='done')h+=''; h+='
'; }); h+='
'; }); document.getElementById('set-tab-roadmap').innerHTML=h; } async function toggleRoadmap(id){ await api('/api/crm/roadmap/'+id,'PUT',{status:'done'}); toast('Отмечено как готово','success');loadRoadmap(); } function openNewRoadmapItem(){ var h='
'; h+='
Целевая версия
Приоритет
'; h+='
Заголовок *
Категория
'; h+='
Описание
'; h+='
'; openModal('Новая задача в roadmap', h); } async function submitRoadmap(){ var title=document.getElementById('rm-title').value; if(!title){toast('Заголовок обязателен','error');return;} var r=await api('/api/crm/roadmap','POST',{ title:title, description:document.getElementById('rm-desc').value||null, category:document.getElementById('rm-cat').value, target_version:document.getElementById('rm-version').value||null, priority:parseInt(document.getElementById('rm-priority').value)||99 }); if(r){toast('Задача добавлена','success');closeModal();loadRoadmap();} } // ===== SYSTEM REPORTS ===== async function loadReports(){ var d=await api('/api/crm/system-reports');if(!d)return; var statusIcons={ok:'✅',warning:'⚠️',error:'❌',info:'ℹ️'}; var statusColors={ok:'rgba(76,175,80,.1)',warning:'rgba(255,152,0,.1)',error:'rgba(201,31,31,.1)',info:'rgba(33,150,243,.1)'}; var statusBorders={ok:'rgba(76,175,80,.3)',warning:'rgba(255,152,0,.3)',error:'rgba(201,31,31,.3)',info:'rgba(33,150,243,.3)'}; var typeLabels={health_check:'Проверка системы',security:'Безопасность',performance:'Производительность',backup:'Бэкап',deployment:'Деплой',audit:'Аудит',custom:'Другое'}; var h='
📊 Отчёты о системе
'; if(!d.length){h+='
Нет отчётов
';} d.forEach(function(r){ var icon=statusIcons[r.status]||'📌'; var bg=statusColors[r.status]||'rgba(136,136,136,.1)'; var border=statusBorders[r.status]||'rgba(136,136,136,.2)'; var typeLabel=typeLabels[r.report_type]||r.report_type; var date=r.created_at?new Date(r.created_at).toLocaleString('ru'):''; h+='
'; h+='
'; h+='
'+icon+' '+esc(r.title)+'
'; h+='
'+typeLabel+''+date+'
'; if(r.summary)h+='
'+esc(r.summary)+'
'; if(r.details){ h+='
Подробности'; h+='
'+esc(r.details)+'
'; h+='
'; } h+='
'; }); document.getElementById('set-tab-reports').innerHTML=h; } // ===== ROLES MANAGEMENT ===== var PERM_LABELS={ dashboard:'Дашборд',dashboard_revenue:'Выручка на дашборде',dashboard_full:'Полный дашборд', clients_view:'Клиенты: просмотр',clients_edit:'Клиенты: редактирование',clients_delete:'Клиенты: удаление',clients_export:'Клиенты: экспорт', orders_view:'Заказы: просмотр',orders_create:'Заказы: создание',orders_edit:'Заказы: редактирование',orders_status:'Заказы: смена статуса',orders_close:'Заказы: закрытие',orders_delete:'Заказы: удаление',orders_export:'Заказы: экспорт',orders_documents:'Заказы: документы', inventory_view:'Склад: просмотр',inventory_edit:'Склад: редактирование',inventory_move:'Склад: движения',inventory_export:'Склад: экспорт', finance_view:'Финансы: просмотр',finance_edit:'Финансы: редактирование',finance_export:'Финансы: экспорт',finance_payroll:'Зарплата', employees_view:'Сотрудники: просмотр',employees_edit:'Сотрудники: редактирование',employees_fire:'Сотрудники: увольнение', marketing_view:'Маркетинг: просмотр',marketing_edit:'Маркетинг: редактирование', calendar_view:'Календарь: просмотр',calendar_edit:'Календарь: запись', app_users_view:'Приложение: просмотр', settings_view:'Настройки: просмотр',settings_edit:'Настройки: редактирование', roles_manage:'Управление ролями',system_admin:'Системное администрирование' }; var PERM_GROUPS={ 'Дашборд':['dashboard','dashboard_revenue','dashboard_full'], 'Клиенты':['clients_view','clients_edit','clients_delete','clients_export'], 'Заказ-наряды':['orders_view','orders_create','orders_edit','orders_status','orders_close','orders_delete','orders_export','orders_documents'], 'Склад':['inventory_view','inventory_edit','inventory_move','inventory_export'], 'Финансы':['finance_view','finance_edit','finance_export','finance_payroll'], 'Сотрудники':['employees_view','employees_edit','employees_fire'], 'Маркетинг':['marketing_view','marketing_edit'], 'Календарь':['calendar_view','calendar_edit'], 'Приложение':['app_users_view'], 'Система':['settings_view','settings_edit','roles_manage','system_admin'] }; async function loadRoles(){ var d=await api('/api/crm/roles');if(!d)return; var h='
🔐 Роли и полномочия
'; h+='
Ранговая система: роль с более высоким рангом может управлять ролями ниже. Суперадмин — максимальные полномочия.
'; d.forEach(function(r){ var perms=r.permissions||{}; var totalPerms=Object.keys(PERM_LABELS).length; var activePerms=Object.keys(perms).filter(function(k){return perms[k]}).length; h+='
'; h+='
'; h+='
'+esc(r.name)+' '+esc(r.code)+' ранг: '+r.rank+''+(r.is_system?' системная':'')+'
'; h+='
'; h+=''+activePerms+'/'+totalPerms+''; if(r.code!=='superadmin')h+=''; else h+='Полные права'; h+='
'; // Permission summary h+='
'; Object.keys(PERM_GROUPS).forEach(function(group){ var groupPerms=PERM_GROUPS[group]; var on=groupPerms.filter(function(p){return perms[p]}).length; var clr=on===groupPerms.length?'var(--green)':on>0?'#FFB800':'var(--dim)'; h+=''+group+' '+on+'/'+groupPerms.length+''; }); h+='
'; }); document.getElementById('set-tab-roles').innerHTML=h; } // ===== EDIT ROLE ===== async function editRole(roleId){ var roles=await api('/api/crm/roles');if(!roles)return; var r=roles.find(function(x){return x.id===roleId});if(!r)return; var perms=r.permissions||{}; var h='
'; h+='
Название роли
Цвет
'; h+='
Полномочия
'; Object.keys(PERM_GROUPS).forEach(function(group){ h+='
'; h+='
'+group+'
'; h+='
'; PERM_GROUPS[group].forEach(function(p){ var checked=perms[p]?'checked':''; var label=PERM_LABELS[p]||p; // Remove group prefix from label for brevity label=label.replace(/^[^:]+:\s*/,''); h+=''; }); h+='
'; }); h+='
'; openModal('Роль: '+r.name+' (ранг '+r.rank+')', h); } async function saveRole(roleId){ var perms={}; document.querySelectorAll('.er-perm').forEach(function(cb){perms[cb.dataset.perm]=cb.checked;}); var name=document.getElementById('er-name').value; var color=document.getElementById('er-color').value; var r=await api('/api/crm/roles/'+roleId,'PUT',{name:name,color:color,permissions:perms}); if(r&&!r.error){toast('Роль обновлена','success');closeModal();loadRoles();} else if(r&&r.error)toast(r.error,'error'); } // ===== CHAT ===== var chatCurrentRoom=null; var chatPollInterval=null; async function loadChatRooms(){ var d=await api('/api/crm/chat/rooms');if(!d)return; var h=''; d.forEach(function(r){ var isActive=chatCurrentRoom===r.id; var name=r.name; if(!name&&r.type==='direct'&&r.participants){ var other=r.participants.filter(function(p){return p.id!==CURRENT_USER_ID}); name=other.length?other[0].name:'Чат'; } var unread=parseInt(r.unread)||0; var lastMsg=r.last_message||''; if(lastMsg.length>40)lastMsg=lastMsg.substring(0,40)+'...'; var time=r.last_message_at?new Date(r.last_message_at).toLocaleTimeString('ru',{hour:'2-digit',minute:'2-digit'}):''; h+='
'; h+='
'+esc(name||'Чат #'+r.id)+'
'; if(unread)h+=''+unread+''; else if(time)h+=''+time+''; h+='
'; if(lastMsg)h+='
'+esc(lastMsg)+'
'; h+='
'; }); if(!d.length)h='
Нет чатов
'; document.getElementById('chat-rooms-list').innerHTML=h; // Update badge var totalUnread=d.reduce(function(s,r){return s+(parseInt(r.unread)||0)},0); var badge=document.getElementById('chat-badge'); if(badge){if(totalUnread){badge.textContent=totalUnread;badge.style.display='inline';}else{badge.style.display='none';}} } async function openChatRoom(roomId){ chatCurrentRoom=roomId; document.getElementById('chat-input-area').style.display='block'; loadChatRooms(); loadChatMessages(roomId); // Start polling if(chatPollInterval)clearInterval(chatPollInterval); chatPollInterval=setInterval(function(){loadChatMessages(roomId,true)},3000); } var chatLastMsgId=0; async function loadChatMessages(roomId, silent){ var msgs=await api('/api/crm/chat/rooms/'+roomId+'/messages?limit=50'); if(!msgs)return; if(silent&&msgs.length&&msgs[msgs.length-1].id===chatLastMsgId)return; var el=document.getElementById('chat-messages'); var h=''; msgs.forEach(function(m){ var isMe=m.sender_id===CURRENT_USER_ID; var time=new Date(m.created_at).toLocaleTimeString('ru',{hour:'2-digit',minute:'2-digit'}); h+='
'; if(!isMe)h+='
'+esc(m.sender_name||'')+'
'; h+='
'; // Reference (client/order link) if(m.ref_type&&m.ref_label){ h+='
'; h+=(m.ref_type==='client'?'👤 ':'📋 ')+esc(m.ref_label)+'
'; } if(m.text)h+=esc(m.text).replace(/\n/g,'
'); h+='
'+time+'
'; }); if(!msgs.length)h='
Нет сообщений
'; el.innerHTML=h; if(msgs.length)chatLastMsgId=msgs[msgs.length-1].id; if(!silent)el.scrollTop=el.scrollHeight; if(!silent)loadChatRooms(); } async function sendChatMsg(){ if(!chatCurrentRoom)return; var inp=document.getElementById('chat-input'); var text=inp.value.trim(); if(!text)return; inp.value=''; await api('/api/crm/chat/rooms/'+chatCurrentRoom+'/messages','POST',{text:text}); loadChatMessages(chatCurrentRoom); } function chatOpenRef(type,id){ if(type==='client'){showPage('clients');setTimeout(function(){openClient(id)},200);} if(type==='order'){showPage('orders');setTimeout(function(){openOrder(id)},200);} } // New chat dialog async function newChatDialog(){ var emps=await api('/api/crm/employees');if(!emps)return; var h='
'; h+='
'; h+=''; h+='
Участники
'; emps.filter(function(e){return e.is_active&&e.id!==CURRENT_USER_ID}).forEach(function(e){ h+=''; }); h+='
'; openModal('Новый чат', h); } async function createChat(){ var selected=[]; document.querySelectorAll('.nc-participant:checked').forEach(function(cb){selected.push(parseInt(cb.value));}); if(!selected.length){toast('Выберите участников','error');return;} var isGroup=document.getElementById('nc-group-fields').style.display!=='none'; var name=isGroup?document.getElementById('nc-chat-name').value:''; var type=isGroup||selected.length>1?'group':'direct'; var r=await api('/api/crm/chat/rooms','POST',{type:type,name:name||null,participant_ids:selected}); if(r){closeModal();openChatRoom(r.id);loadChatRooms();} } // Send client/order reference in chat function chatSendRef(type, id, label){ if(!chatCurrentRoom){toast('Откройте чат','error');return;} api('/api/crm/chat/rooms/'+chatCurrentRoom+'/messages','POST',{ text:(type==='client'?'Клиент: ':'Заказ: ')+label, ref_type:type, ref_id:id }).then(function(){loadChatMessages(chatCurrentRoom);}); } // Add CURRENT_USER_ID variable // ===== INIT ===== // ===== BROWSER NOTIFICATIONS ===== async function checkNewRequests(){ var d=await api('/api/crm/badges');if(!d)return; if(d.requests>0&&Notification.permission==='granted'){ new Notification('Stage-X CRM',{body:d.requests+' новых заявок',icon:'/favicon.ico'}); } } // Request notification permission on first load if('Notification' in window && Notification.permission==='default'){ setTimeout(function(){Notification.requestPermission()},5000); } if(TOKEN) initApp(); // Auto-refresh dashboard every 60s var autoRefreshInterval; function startAutoRefresh(){ clearInterval(autoRefreshInterval); autoRefreshInterval=setInterval(function(){ if(document.querySelector('#page-dashboard.active')){loadDashboard();loadBadges();} else{loadBadges();} },60000); }