intra_monitor/public/js/ui.js
2026-03-18 14:19:29 +03:00

870 lines
43 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

// ── Column visibility ─────────────────────────────────────────────
const ALL_COLS = [
{ key:'num', label:'# Заявка', def:true },
{ key:'title', label:'Тема', def:true },
{ key:'intrastatus', label:'Статус в интре', def:true },
{ key:'client', label:'Клиент', def:true },
{ key:'date', label:'Время ответа', def:true },
{ key:'created', label:'Создана', def:true },
{ key:'preview', label:'Превью/Заметка', def:true },
{ key:'status', label:'Статус', def:true },
];
let visibleCols = new Set(ALL_COLS.filter(c=>c.def).map(c=>c.key));
let colMenuOpen = false;
let filteredData = [];
let statFilter = 'all';
let selectedRows = new Set();
let mainTab = 'all'; // 'all' | 'open' | 'done' | 'empty'
function setMainTab(tab, el) {
mainTab = tab;
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('tab-btn-active'));
if (el) el.classList.add('tab-btn-active');
renderTable();
}
function isTaskOpen(item) {
const sn = (item.task._statusname || item.task.statusname || '').toLowerCase();
return sn.includes('открыт') || sn.includes('в работе') || sn.includes('новая') || sn.includes('new') || sn.includes('назначен');
}
function isTaskDone(item) {
const sn = (item.task._statusname || item.task.statusname || '').toLowerCase();
return sn.includes('выполнен') || sn.includes('закрыт') || sn.includes('решен');
}
function isTaskEmpty(item) {
return !item.lastComment;
}
// ── Helpers ───────────────────────────────────────────────────────
function getTaskTags(task){const r=task.tags?.data??task.tags??[];return r.map(t=>({id:t.id??t,name:t.name??String(t)}));}
function buildTagIndex(){return [];}
function renderTagFilter(){}
function abbrevName(name){
if(!name||name==='—') return name||'—';
const p=name.trim().split(/\s+/);
if(p.length===1) return p[0];
return p[0]+' '+p[1][0]+'.';
}
const AV_COLORS=['#5b7fff','#ff4d4f','#52c41a','#fa8c16','#722ed1','#13c2c2','#eb2f96'];
function avatarColor(n){let h=0;for(let i=0;i<(n||'').length;i++)h=(h*31+n.charCodeAt(i))%AV_COLORS.length;return AV_COLORS[h];}
function avatarInitials(n){
if(!n||n==='—') return'?';
const p=n.trim().split(/\s+/);
return p.length>1?(p[0][0]+p[1][0]).toUpperCase():n.slice(0,2).toUpperCase();
}
// ── Chips ─────────────────────────────────────────────────────────
function setChip(el,on){
if(!el)return;
el.classList.toggle('chip-on',on);
el.classList.toggle('chip-off',!on);
}
function syncAllChip(){setChip(document.getElementById('chip-all'),STATE_CHIP_KEYS.every(k=>stateChips[k]));}
function toggleStateChip(name){
stateChips[name]=!stateChips[name];
setChip(document.getElementById('chip-'+name),stateChips[name]);
syncAllChip(); renderTable();
}
function toggleAllChip(){
const t=!STATE_CHIP_KEYS.every(k=>stateChips[k]);
STATE_CHIP_KEYS.forEach(k=>{stateChips[k]=t;setChip(document.getElementById('chip-'+k),t);});
setChip(document.getElementById('chip-all'),t);
renderTable();
}
// ── Init mark chips + bulk-select from MARKS_CONFIG ───────────────
function initMarkChips(){
// Chip dropdown items
const container=document.getElementById('mark-chips-container');
if(container){
container.innerHTML=MARKS_CONFIG.map(m=>`
<div class="chip-dd-item chip-dd-mark" id="chipdd-${m.key}" onclick="chipClickLabel('${m.key}')">
<span class="chip-dd-dot" style="background:${m.chipDotColor};"></span>
<span class="chip-dd-lbl">${m.chipLabel}</span>
<span class="chip-dd-cnt" id="chip-${m.key}-cnt">0</span>
</div>`).join('');
}
// Bulk-status select options
const sel=document.getElementById('bulk-status-sel');
if(sel){
const clearOpt=sel.querySelector('option[value="clear"]');
MARKS_CONFIG.slice().reverse().forEach(m=>{
const opt=document.createElement('option');
opt.value=m.key; opt.textContent=m.label;
sel.insertBefore(opt, clearOpt);
});
}
}
function toggleMentionedFilter(){
mentionedFilterOn=!mentionedFilterOn;
setChip(document.getElementById('chip-mentioned'),mentionedFilterOn);
renderTable();
}
function toggleChipDropdown() {
const panel = document.getElementById('chip-dropdown-panel');
if (!panel) return;
panel.classList.toggle('chip-dd-open');
}
function updateChipDropdownLabel() {
const allOn = STATE_CHIP_KEYS.every(k => stateChips[k]);
const activeKeys = STATE_CHIP_KEYS.filter(k => stateChips[k]);
const lbl = document.getElementById('chip-dropdown-label');
if (!lbl) return;
if (allOn) {
lbl.textContent = 'Все статусы';
} else if (activeKeys.length === 0) {
lbl.textContent = 'Ни один';
} else if (activeKeys.length === 1) {
const key = activeKeys[0];
if (key === 'unmarked') { lbl.textContent = 'Без статуса'; return; }
const m = MARKS_CONFIG.find(x => x.key === key);
lbl.textContent = m ? m.chipLabel : key;
} else {
lbl.textContent = `Статусов: ${activeKeys.length}`;
}
}
function syncChipDropdownItems() {
const allOn = STATE_CHIP_KEYS.every(k => stateChips[k]);
const allItem = document.getElementById('chipdd-all');
if (allItem) allItem.classList.toggle('chip-dd-active', allOn);
MARKS_CONFIG.forEach(m => {
const el = document.getElementById(`chipdd-${m.key}`);
if (el) el.classList.toggle('chip-dd-active', !!stateChips[m.key]);
});
const uEl = document.getElementById('chipdd-unmarked');
if (uEl) uEl.classList.toggle('chip-dd-active', !!stateChips['unmarked']);
updateChipDropdownLabel();
}
function setStatFilter(f,el){
statFilter=f;
document.querySelectorAll('.stat-card').forEach(c=>c.classList.remove('active'));
if(el)el.classList.add('active');
renderTable();
}
function setNavView(v,e){}
// ── Date filter ───────────────────────────────────────────────────
function taskMatchesDate(task){
const v=document.getElementById('date-filter')?.value||'0';
if(v==='0')return true;
const now=new Date(),upd=new Date(task.updatedat);
if(v==='today')return upd.toDateString()===now.toDateString();
if(v==='yesterday'){const y=new Date(now);y.setDate(y.getDate()-1);return upd.toDateString()===y.toDateString();}
return upd>=new Date(now.setDate(now.getDate()-parseInt(v)));
}
// ── Status badges ─────────────────────────────────────────────────
function internalStatusBadge(tid){
const m=MARKS_CONFIG.find(m=>isMark(tid,m.key));
if(m) return`<span class="status-badge ${m.badgeClass}"><span class="dot"></span>${m.label}</span>`;
return`<span class="status-badge sb-none"><span class="dot"></span>Без статуса</span>`;
}
// ── IntraDesk status badge ────────────────────────────────────────
function intraStatusBadge(task) {
const name = task._statusname || task.statusname || '';
if (!name) {
// Показываем числовой id статуса как fallback
const sid = task.statusid || task.status;
if (sid) return `<span class="status-badge sb-intra-other"><span class="dot"></span>#${sid}</span>`;
return '<span style="color:var(--text3);font-size:11px;">—</span>';
}
const lc = name.toLowerCase();
let cls = 'sb-intra-other';
if (lc.includes('открыт') || lc.includes('new') || lc.includes('открытый')) cls = 'sb-intra-open';
else if (lc.includes('работ') || lc.includes('прогресс') || lc.includes('назначен')) cls = 'sb-intra-work';
else if (lc.includes('выполнен') || lc.includes('закрыт') || lc.includes('решен')) cls = 'sb-intra-done';
else if (lc.includes('ожидан') || lc.includes('ожидает') || lc.includes('hold')) cls = 'sb-intra-hold';
return `<span class="status-badge ${cls}"><span class="dot"></span>${esc(name)}</span>`;
}
// Who answered — ring class for avatar
function whoAnsweredRing(lc){
if(!lc) return 'ring-none';
return lc.usertype===10 ? 'ring-internal' : 'ring-external';
}
// Who answered — badge html
function whoAnsweredBadge(lc){
if(!lc) return`<span class="status-badge sb-none"><span class="dot"></span>Нет ответа</span>`;
if(lc.usertype===10) return`<span class="status-badge sb-internal"><span class="dot"></span>Сотрудник</span>`;
return`<span class="status-badge sb-external"><span class="dot"></span>Клиент</span>`;
}
// Row color — ONLY by internal marks, not by who answered
function rowColorClass(tid){
const m=MARKS_CONFIG.find(m=>isMark(tid,m.key));
return m?m.rowClass:'';
}
// ── Who set a mark ────────────────────────────────────────────────
function markSetByHtml(tid) {
const lines=MARKS_CONFIG
.filter(cfg=>marksCache[tid]?.[cfg.key]?.active&&marksCache[tid][cfg.key].by)
.map(cfg=>{
const mc=marksCache[tid][cfg.key];
return`<span class="mset-item">👤 ${esc(abbrevName(mc.by))} · «${cfg.label}»${mc.at?` · ${timeAgo(new Date(mc.at))}`:''}`;
});
return lines.length?`<div class="sp-mark-setby">${lines.join('')}</div>`:'';
}
// ── Inline status picker ──────────────────────────────────────────
function statusPickerHtml(tid){
const items=MARKS_CONFIG.map(m=>`
<div class="isp-item ${isMark(tid,m.key)?'isp-active':''}" onclick="toggleMark('${tid}','${m.key}',event)">
<span class="isp-dot" style="background:${m.ispColor};"></span>${m.label}
</div>`).join('');
return`<div class="inline-status-picker" onmouseenter="ispShow(this)" onmouseleave="ispHide(this)">
${internalStatusBadge(tid)}
<div class="isp-dropdown">
<div class="isp-dropdown-inner">${items}</div>
</div>
</div>`;
}
function ispShow(el){
const dd=el.querySelector('.isp-dropdown');
if(!dd) return;
const r=el.getBoundingClientRect();
dd.style.top=(r.bottom)+'px';
dd.style.left=r.left+'px';
dd.style.display='block';
}
function ispHide(el){
const dd=el.querySelector('.isp-dropdown');
if(dd) dd.style.display='none';
}
// ── Column picker ─────────────────────────────────────────────────
function toggleColMenu(e){
e.stopPropagation();
colMenuOpen=!colMenuOpen;
document.getElementById('col-menu')?.classList.toggle('open',colMenuOpen);
}
document.addEventListener('click',()=>{
colMenuOpen=false;
document.getElementById('col-menu')?.classList.remove('open');
});
function toggleCol(key){
if(visibleCols.has(key))visibleCols.delete(key);else visibleCols.add(key);
renderColMenu(); renderTable();
}
function renderColMenu(){
const m=document.getElementById('col-menu');
if(!m)return;
m.innerHTML=ALL_COLS.map(c=>`
<label class="col-menu-item" onclick="event.stopPropagation()">
<input type="checkbox" ${visibleCols.has(c.key)?'checked':''} onchange="toggleCol('${c.key}')">
<span>${c.label}</span>
</label>`).join('');
}
// ── Sort state ────────────────────────────────────────────────────
let sortCol = null; // 'date' | 'created'
let sortDir = 'desc';
function toggleSort(col) {
if (sortCol === col) sortDir = sortDir === 'desc' ? 'asc' : 'desc';
else { sortCol = col; sortDir = 'desc'; }
renderTable();
}
// ── Pagination state ──────────────────────────────────────────────
let currentPage = 1;
function getPageSize() { const v=parseInt(document.getElementById('opt-page-size')?.value||'50'); return v||0; }
function setPage(p) { currentPage = p; renderTable(true); }
function onPageSizeChange() { currentPage = 1; renderTable(); }
// ── Chip logic: click label = solo, click dot = toggle ────────────
function chipClickLabel(name) {
STATE_CHIP_KEYS.forEach(k => { stateChips[k] = (k === name); });
syncChipDropdownItems();
renderTable();
}
function chipClickDot(name, e) {
e && e.stopPropagation();
stateChips[name] = !stateChips[name];
syncChipDropdownItems();
renderTable();
}
function toggleAllChip() {
const t = !STATE_CHIP_KEYS.every(k => stateChips[k]);
STATE_CHIP_KEYS.forEach(k => { stateChips[k] = t; });
syncChipDropdownItems();
renderTable();
}
// ── Render table ──────────────────────────────────────────────────
function renderTable(keepPage){
if(!keepPage) currentPage = 1;
const search =document.getElementById('search-input')?.value?.toLowerCase()||'';
const remoteOnly =document.getElementById('opt-remote-only')?.checked;
filteredData=allData.filter(item=>{
const tid=String(item.task.id);
const marked=MARKS_CONFIG.some(m=>isMark(tid,m.key));
for(const m of MARKS_CONFIG){if(isMark(tid,m.key)&&!stateChips[m.key])return false;}
if(!marked&&!stateChips.unmarked) return false;
if(mentionedFilterOn&&!mentionsData.some(m=>String(m.task_id)===tid)) return false;
if(search&&!(item.task.name?.toLowerCase().includes(search)||String(item.task.tasknumber).includes(search)||(item.task._clientname||item.task.clientname||'').toLowerCase().includes(search))) return false;
if(remoteOnly&&item.task.service!==84683) return false;
if(!taskMatchesDate(item.task)) return false;
if(statFilter!=='all'&&getAuthorType(item)!==statFilter) return false;
// Main tab filter
if(mainTab==='open' && !isTaskOpen(item)) return false;
if(mainTab==='done' && !isTaskDone(item)) return false;
if(mainTab==='empty' && !isTaskEmpty(item)) return false;
return true;
});
// Sort
if(sortCol) {
filteredData = [...filteredData].sort((a,b) => {
const da = sortCol==='date'
? (a.lastComment?.eventat||a.task.updatedat||'')
: (a.task.createdat||'');
const db = sortCol==='date'
? (b.lastComment?.eventat||b.task.updatedat||'')
: (b.task.createdat||'');
const cmp = da < db ? -1 : da > db ? 1 : 0;
return sortDir==='asc' ? cmp : -cmp;
});
}
// Counts
const base=allData.filter(i=>taskMatchesDate(i.task));
const g=id=>document.getElementById(id);
const sv=(id,v)=>{const e=g(id);if(e)e.textContent=v;};
sv('chip-all-cnt', base.length);
MARKS_CONFIG.forEach(m=>{
sv(`chip-${m.key}-cnt`, base.filter(i=>isMark(String(i.task.id),m.key)).length);
});
sv('chip-unmarked-cnt', base.filter(i=>{
const tid=String(i.task.id);return !MARKS_CONFIG.some(m=>isMark(tid,m.key));
}).length);
sv('stat-total', base.length);
sv('stat-external', base.filter(i=>getAuthorType(i)==='external').length);
sv('stat-internal', base.filter(i=>getAuthorType(i)==='internal').length);
sv('stat-nocomment', base.filter(i=>getAuthorType(i)==='nocomment').length);
sv('stat-unread', allData.filter(i=>isUnread(i.task)).length);
sv('nav-badge-tasks', filteredData.length);
sv('topbar-sub', `· ${filteredData.length} заявок`);
sv('total-loaded', filteredData.length);
sv('total-count', allData.length);
sv('tf-info', `${filteredData.length} из ${allData.length}`);
// Tab counts (using allData for totals regardless of chip filter)
sv('tcnt-all', allData.length);
sv('tcnt-open', allData.filter(i=>isTaskOpen(i)).length);
sv('tcnt-done', allData.filter(i=>isTaskDone(i)).length);
sv('tcnt-empty', allData.filter(i=>isTaskEmpty(i)).length);
if(!filteredData.length){
g('table-wrap').innerHTML=`<div class="state-box"><span class="icon">🔍</span><div class="msg">Заявок не найдено</div><div class="sub">Измените фильтры</div></div>`;
const pb=g('pagination-bar'); if(pb){pb.style.display='none';pb.innerHTML='';}
return;
}
// Pagination slice
const PAGE_SIZE = getPageSize();
const totalPages = PAGE_SIZE > 0 ? Math.ceil(filteredData.length / PAGE_SIZE) : 1;
if(currentPage > totalPages) currentPage = totalPages;
const pageData = PAGE_SIZE > 0 ? filteredData.slice((currentPage-1)*PAGE_SIZE, currentPage*PAGE_SIZE) : filteredData;
const sc=k=>visibleCols.has(k);
const sortArrow = col => sortCol===col ? (sortDir==='asc' ? ' ↑' : ' ↓') : ' ↕';
const sortStyle = col => `cursor:pointer;user-select:none;${sortCol===col?'color:var(--accent);':'color:inherit;'}`;
const thead=`<thead><tr>
<th style="width:32px;padding:8px 6px;"></th>
${sc('num') ?'<th style="width:70px;">#</th>' :''}
${sc('title') ?'<th>Тема</th>' :''}
${sc('intrastatus')?'<th style="width:110px;">Статус в интре</th>':''}
${sc('status') ?'<th style="width:130px;">Статус</th>' :''}
${sc('client') ?'<th style="width:170px;">Кто ответил</th>' :''}
${sc('date') ?`<th style="width:105px;${sortStyle('date')}" onclick="toggleSort('date')">Время ответа<span style="font-size:10px;opacity:.6;">${sortArrow('date')}</span></th>` :''}
${sc('created')?`<th style="width:95px;${sortStyle('created')}" onclick="toggleSort('created')">Создана<span style="font-size:10px;opacity:.6;">${sortArrow('created')}</span></th>` :''}
${sc('preview')?'<th style="width:135px;">Превью</th>' :''}
</tr></thead>`;
const rows=pageData.map(item=>{
const {task,lastComment}=item;
const tid=String(task.id);
const num=task.tasknumber||task.id;
const idx=allData.indexOf(item);
const unread=isUnread(task);
const note=getNote(tid);
const cc=rowColorClass(tid);
const rowCls=((unread&&!cc?'row-unread ':'')+cc+(currentModalIdx===idx?' active-row':'')).trim();
const authorName=lastComment?.username||task.updatedby||task.username||'';
const orgName=task._clientname||task.clientname||task.organization||'';
const unreadDot=unread?'<span class="unread-dot-inline"></span>':'';
const cntBadge=getCommentCount(tid)?`<div class="comment-count-badge">💬 ${getCommentCount(tid)}</div>`:'';
const prevCell=note
?`<div class="note-indicator" title="${esc(note)}">📝 ${esc(note.slice(0,42))}${note.length>42?'…':''}</div>`
:(lastComment?`<div class="preview-text">${esc(stripHtml(lastComment.text||'').slice(0,52))}</div>`:'');
const whoLabel=lastComment
?(lastComment.usertype===10
?'<span class="who-label who-internal">● Сотрудник</span>'
:'<span class="who-label who-external">● Клиент</span>')
:(task.updatedby
?`<span class="who-label who-none">${esc(abbrevName(task.updatedby))}</span>`
:'<span class="who-label who-none">нет ответа</span>');
return`<tr onclick="openModal(${idx})" data-idx="${idx}" class="${rowCls}">
<td style="padding:8px 6px;" onclick="event.stopPropagation()">
<input type="checkbox" class="row-check" data-idx="${idx}" style="accent-color:var(--accent);" ${selectedRows.has(idx)?'checked':''} onchange="toggleRowCheck(${idx},this)">
</td>
${sc('num') ?`<td><span class="task-num">#${num}</span>${unreadDot}</td>`:''}
${sc('title') ?`<td><div class="task-title-main" title="${esc(task.name)}">${esc(task.name)}</div>${orgName?`<div class="task-title-sub">${esc(orgName)}</div>`:''}</td>`:''}
${sc('intrastatus')?`<td>${intraStatusBadge(task)}</td>`:''}
${sc('status') ?`<td onclick="event.stopPropagation()">${statusPickerHtml(tid)}</td>`:''}
${sc('client') ?`<td><div class="client-info">
<div class="client-avatar ${whoAnsweredRing(lastComment)}" style="background:${avatarColor(authorName)};color:#fff;">${avatarInitials(authorName)}</div>
<div>
<div class="client-name">${esc(abbrevName(authorName)||'—')}</div>
${whoLabel}
</div>
</div></td>`:''}
${sc('date') ?`<td>${lastComment?`<div class="date-main">${fmtDateShort(new Date(lastComment.eventat))}</div><div class="date-sub">${fmtTime(new Date(lastComment.eventat))}</div><div class="date-ago">${timeAgo(new Date(lastComment.eventat))}</div>`:task.updatedat?`<div class="date-main">${fmtDateShort(new Date(task.updatedat))}</div><div class="date-sub">${fmtTime(new Date(task.updatedat))}</div><div class="date-ago">${timeAgo(new Date(task.updatedat))}</div>`:'<div class="date-main">—</div>'}</td>`:''}
${sc('created')?`<td>${task.createdat?`<div class="date-main">${fmtDateShort(new Date(task.createdat))}</div><div class="date-sub">${fmtTime(new Date(task.createdat))}</div>`:'<div class="date-main">—</div>'}</td>`:''}
${sc('preview')?`<td>${prevCell}${cntBadge}</td>`:''}
</tr>`;
}).join('');
g('table-wrap').innerHTML=`<table class="tasks-table">${thead}<tbody>${rows}</tbody></table>`;
// Pagination bar
const pb = g('pagination-bar');
if(pb) {
if(totalPages <= 1 || PAGE_SIZE === 0){ pb.style.display='none'; pb.innerHTML=''; }
else {
pb.style.display='flex';
const start=(currentPage-1)*PAGE_SIZE+1, end=Math.min(currentPage*PAGE_SIZE,filteredData.length);
let pages='';
for(let p=1;p<=totalPages;p++){
if(p===1||p===totalPages||Math.abs(p-currentPage)<=2){
pages+=`<button class="pg-btn${p===currentPage?' pg-active':''}" onclick="setPage(${p})">${p}</button>`;
} else if(Math.abs(p-currentPage)===3){
pages+=`<span class="pg-ellipsis">…</span>`;
}
}
pb.innerHTML=`
<button class="pg-btn" onclick="setPage(${currentPage-1})" ${currentPage===1?'disabled':''}></button>
${pages}
<button class="pg-btn" onclick="setPage(${currentPage+1})" ${currentPage===totalPages?'disabled':''}></button>
<span class="pg-info">${start}${end} из ${filteredData.length}</span>`;
}
}
}
function fmtDateShort(d){return d.toLocaleDateString('ru-RU',{day:'2-digit',month:'2-digit',year:'2-digit'});}
function fmtTime(d){return d.toLocaleTimeString('ru-RU',{hour:'2-digit',minute:'2-digit'});}
function toggleSelectAll(cb){document.querySelectorAll('.row-check').forEach(c=>{const i=+c.dataset.idx;if(cb.checked)selectedRows.add(i);else selectedRows.delete(i);c.checked=cb.checked;});updateBulkBar();}
function toggleRowCheck(idx,cb){if(cb.checked)selectedRows.add(idx);else selectedRows.delete(idx);updateBulkBar();}
function updateBulkBar(){
const info=document.getElementById('bulk-sel-info');
const cnt =document.getElementById('bulk-sel-count');
const sel =document.getElementById('bulk-status-sel');
if(!info)return;
if(selectedRows.size>0){
info.style.display='flex';
if(cnt)cnt.textContent=`${selectedRows.size} выбрано`;
if(sel)sel.value='';
}else{
info.style.display='none';
}
}
function bulkApplyStatusFromSelect(sel){
const v=sel.value;
sel.value='';
if(!v)return;
bulkApplyStatus(v==='clear'?null:v);
}
function bulkApplyStatus(markType){
if(!selectedRows.size)return;
const now=new Date().toISOString();
const tids=[...selectedRows].map(idx=>allData[idx]?String(allData[idx].task.id):null).filter(Boolean);
for(const tid of tids){
if(!marksCache[tid])marksCache[tid]={};
if(!markType){
const active=STATUS_MARKS.find(m=>marksCache[tid][m]?.active);
STATUS_MARKS.forEach(m=>{marksCache[tid][m]={...(marksCache[tid][m]||{}),active:false};});
if(active) apiPost('/api/marks',{task_id:tid,mark_type:active,value:false,clear_others:false});
}else{
STATUS_MARKS.forEach(m=>{marksCache[tid][m]={...(marksCache[tid][m]||{}),active:false};});
marksCache[tid][markType]={active:true,by:currentUser?.username,at:now};
apiPost('/api/marks',{task_id:tid,mark_type:markType,value:true,clear_others:true});
}
}
renderTable();
if(currentModalIdx!==null){
const openTid=String(allData[currentModalIdx]?.task?.id);
if(tids.includes(openTid))renderSidePanel(currentModalIdx);
}
}
// ── SIDE PANEL open/close ─────────────────────────────────────────
function openModal(idx){
if(idx===null||idx===undefined||!allData[idx]) return;
currentModalIdx=idx; currentTab='last';
const {task,lastComment}=allData[idx];
// Immediately mark read & update that row in-place
if(isUnread(task)){
markSeen(task.id,task.updatedat);
const row=document.querySelector(`tbody tr[data-idx="${idx}"]`);
if(row){
['row-unread',...MARKS_CONFIG.map(m=>m.rowClass)].forEach(c=>row.classList.remove(c));
const cc=rowColorClass(String(task.id));
if(cc) row.classList.add(cc);
row.querySelector('.unread-dot-inline')?.remove();
}
const su=document.getElementById('stat-unread');
if(su) su.textContent=allData.filter(i=>isUnread(i.task)).length;
}
// Mark mentions read
if(mentionsData.some(m=>String(m.task_id)===String(task.id)&&!m.read_at)){
apiPost('/api/mentions/read',{task_id:String(task.id)}).then(r=>{
if(r){
mentionUnread=r.unread||0;
mentionsData=mentionsData.map(m=>
String(m.task_id)===String(task.id)?{...m,read_at:new Date().toISOString()}:m);
renderMentionsBell();
}
});
}
// Highlight active row
document.querySelectorAll('.tasks-table tbody tr').forEach(r=>r.classList.remove('active-row'));
document.querySelector(`tbody tr[data-idx="${idx}"]`)?.classList.add('active-row');
// Show side panel
const dc=document.getElementById('detail-col');
if(dc) dc.classList.remove('hidden');
renderSidePanel(idx);
loadCommentsForTask(String(task.id));
}
function closeModal(){
const dc=document.getElementById('detail-col');
if(dc) dc.classList.add('hidden');
document.querySelectorAll('.tasks-table tbody tr').forEach(r=>r.classList.remove('active-row'));
currentModalIdx=null;
}
function closeModalOnOverlay(){}
document.addEventListener('keydown',e=>{if(e.key==='Escape')closeModal();});
// ── SIDE PANEL render ─────────────────────────────────────────────
function renderSidePanel(idx){
const container=document.getElementById('detail-content');
if(!container) return;
if(idx===null||idx===undefined||!allData[idx]){
container.innerHTML='';
return;
}
const {task,lastComment}=allData[idx];
const tid=String(task.id);
const num=task.tasknumber||task.id;
const note=getNote(tid),noteBy=getNoteBy(tid),noteAt=getNoteAt(tid);
const cnt=getCommentCount(tid);
const authorName=lastComment?.username||task.username||'—';
const rawDesc=task.description||task.descriptionhtml||task.body||task.text||'';
const noteHtml=(openNoteIdx===idx)
?`<div class="detail-note-editor">
<textarea id="detail-note-ta" placeholder="Заметка...">${esc(note)}</textarea>
<div class="detail-note-actions">
<button class="btn btn-primary" style="font-size:11px;padding:5px 12px;" onclick="saveNoteAndClose(${idx},event)">Сохранить</button>
<button class="btn btn-default" style="font-size:11px;padding:5px 12px;" onclick="closeNoteEditor()">Отмена</button>
</div>
</div>`
:note
?`<div class="detail-note-text" onclick="openNoteEditor(${idx},event)">📝 ${renderMentions(note)}
<div style="font-size:10px;color:var(--text3);margin-top:3px;">${noteBy?`👤 ${esc(abbrevName(noteBy))}`:''}${noteAt?` · ${fmtDate(new Date(noteAt))}`:''} </div>
</div>`
:`<div class="detail-note-empty" onclick="openNoteEditor(${idx},event)">+ Добавить заметку</div>`;
container.innerHTML=`
<div class="sp-header">
<div style="flex:1;min-width:0;">
<div class="sp-badges">
${internalStatusBadge(tid)}
${whoAnsweredBadge(lastComment)}
</div>
<div class="detail-title">#${num} ${esc(task.name)}</div>
<div class="detail-author">${esc(abbrevName(authorName))}${task.clientname||task.organization?` · ${esc(task.clientname||task.organization)}`:''}</div>
</div>
<div class="sp-header-actions">
<button class="sp-close-btn" onclick="closeModal()">×</button>
</div>
</div>
<div class="sp-note">
<a class="sp-ext-link" href="${TASK_URL}${num}" target="_blank" onclick="event.stopPropagation()">↗ Открыть в IntraDesk</a>
</div>
<div class="sp-marks">
${MARKS_CONFIG.map(m=>{
const active=isMark(tid,m.key);
return`<button class="mark-btn ${m.btnClass}${active?' mbtn-active':''}" data-mark="${m.key}" onclick="toggleMark('${tid}','${m.key}',event)">${m.btnIcon} ${m.btnLabel}</button>`;
}).join('')}
</div>
${markSetByHtml(tid)}
<div class="sp-note">${noteHtml}</div>
<div class="sp-tabs">
<div class="detail-tab ${currentTab==='last'?'active':''}" id="dtab-last" onclick="switchTab('last')">Последний комментарий</div>
<!--<div class="detail-tab ${currentTab==='comments'?'active':''}" id="dtab-comments" onclick="switchTab('comments')">💬 Наши${cnt?` (${cnt})`:''}</div>-->
<div class="detail-tab ${currentTab==='history'?'active':''}" id="dtab-history" onclick="switchTab('history')">Загрузить заявку</div>
</div>
<div class="sp-body" id="sp-body">
${renderTabContent(idx,currentTab,rawDesc,lastComment)}
</div>
<!-- <div class="sp-composer">
<div style="position:relative;">
<textarea class="composer-textarea" id="composer-ta"
placeholder="Написать комментарий... (Ctrl+Enter)" oninput="onCommentInput(this)"></textarea>
<div class="autocomplete-popup" id="autocomplete-popup"
style="display:none;position:absolute;bottom:100%;left:0;margin-bottom:4px;"></div>
</div>
<div class="composer-footer">
<button class="composer-tool" title="@упомянуть" onclick="insertAtSymbol()">@</button>
<button class="btn btn-primary btn-send" style="margin-left:auto;" onclick="submitComment()">Отправить</button>
</div>
</div> -->`;
const ta=document.getElementById('composer-ta');
if(ta){
ta.addEventListener('keydown',e=>{
if(e.ctrlKey&&e.key==='Enter'){e.preventDefault();submitComment();}
handleAutocompleteKey(e);
});
}
if(openNoteIdx===idx){
const nta=document.getElementById('detail-note-ta');
if(nta){nta.focus();nta.setSelectionRange(nta.value.length,nta.value.length);}
}
}
// aliases for SSE/mentions compatibility
function renderDetailPanel(idx){renderSidePanel(idx);}
function renderModalContent(idx){renderSidePanel(idx);}
function openDetail(idx){openModal(idx);}
function closeDetail(){closeModal();}
// ── Tab content ───────────────────────────────────────────────────
function renderTabContent(idx,tab,rawDesc,lastComment){
const {task}=allData[idx];
const tid=String(task.id);
if(tab==='last'){
const org=task.clientname||task.organization||'';
const authorName=lastComment?.username||'—';
const created=task.createdat?new Date(task.createdat):'';
const descHtml=rawDesc
?`<!--<div class="sp-section"><div class="sp-section-title">Описание</div><div class="detail-description">${sanitizeHtml(rawDesc)}</div></div>-->`:'';
const actHtml=lastComment
?`<div class="sp-section"><div class="sp-section-title">Последняя активность</div>
<div class="last-activity-item">
<div class="la-avatar" style="background:${avatarColor(authorName)};">${avatarInitials(authorName)}</div>
<div>
<div><span class="la-name">${esc(abbrevName(authorName))}</span><span class="la-time">${timeAgo(new Date(lastComment.eventat))}</span></div>
<div class="la-text">${esc(stripHtml(lastComment.text||'').slice(0,180))}</div>
</div>
</div>
</div>`:'';
const executorName = task._executorname || '';
const executorGroupName = task._executorgroupname || '';
return`
<div class="sp-section">
<div class="detail-meta-grid">
<!-- ${org?`<div class="detail-meta-row"><span class="detail-meta-label">Клиент</span><span class="detail-meta-val"><span class="client-dot"></span>${esc(org)}</span></div>`:''}
${created?`<div class="detail-meta-row"><span class="detail-meta-label">Создана</span><span class="detail-meta-val">${fmtDate(created)}</span></div>`:''}
<div class="detail-meta-row"><span class="detail-meta-label">Кто ответил</span>${whoAnsweredBadge(lastComment)}</div>
<div class="detail-meta-row"><span class="detail-meta-label">Статус</span>${internalStatusBadge(tid)}</div> -->
${executorGroupName?`<div class="detail-meta-row"><span class="detail-meta-label">Группа</span><span class="detail-meta-val">👥 ${esc(executorGroupName)}</span></div>`:''}
${executorName?`<div class="detail-meta-row"><span class="detail-meta-label">Исполнитель</span><span class="detail-meta-val">👤 ${esc(executorName)}</span></div>`:''}
</div>
</div>
${descHtml}${actHtml}`;
}
if(tab==='comments'){
return renderCommentsHtml(tid,commentsCache[tid]||[]);
}
if(tab==='history'){
setTimeout(()=>loadAndRenderHistory(idx,'sp-body'),10);
return`<div class="state-box" style="padding:28px 0;"><div class="spinner" style="width:20px;height:20px;margin-bottom:8px;"></div><div class="msg" style="font-size:12px;">Загружаю историю...</div></div>`;
}
return'';
}
function switchTab(tab){
currentTab=tab;
if(currentModalIdx===null) return;
['last','comments','history'].forEach(t=>document.getElementById(`dtab-${t}`)?.classList.toggle('active',t===tab));
const {task}=allData[currentModalIdx];
const rawDesc=task.description||task.descriptionhtml||task.body||task.text||'';
const sb=document.getElementById('sp-body');
if(sb) sb.innerHTML=renderTabContent(currentModalIdx,tab,rawDesc,allData[currentModalIdx].lastComment);
}
// ── Comments ──────────────────────────────────────────────────────
function renderCommentsHtml(tid,comments){
if(!comments.length) return`<div style="padding:20px 0;text-align:center;color:var(--text3);font-size:12px;">Комментариев нет. Напишите первый!</div>`;
return`<div style="display:flex;flex-direction:column;gap:8px;">
${comments.map(c=>{
const own=c.author===currentUser.username;
const canEdit=own||currentUser.is_admin;
const te=c.text.replace(/\\/g,'\\\\').replace(/'/g,"\\'").replace(/\n/g,'\\n');
return`<div class="detail-comment ${own?'own':''}" id="cmt-${c.id}">
<div class="dc-header">
<div class="client-avatar" style="background:${avatarColor(c.author)};color:#fff;width:20px;height:20px;font-size:8px;flex-shrink:0;">${avatarInitials(c.author)}</div>
<span class="dc-author">${esc(abbrevName(c.author))}</span>
<span class="dc-time">${fmtDate(new Date(c.created_at))}</span>
${c.updated_at?`<span class="dc-edited">(ред.)</span>`:''}
</div>
<div class="dc-text" id="cmt-text-${c.id}">${renderMentions(c.text)}</div>
${canEdit?`<div class="dc-actions">
<span class="dc-btn" onclick="startEditComment(${c.id},'${te}')">✎ Ред.</span>
<span class="dc-btn del" onclick="deleteComment(${c.id},'${tid}')">✕ Удалить</span>
</div>`:''}
<div id="cmt-edit-${c.id}" style="display:none;margin-top:6px;flex-direction:column;gap:5px;">
<textarea style="width:100%;background:var(--surface2);border:1px solid var(--border);border-radius:var(--radius-sm);font-family:'Inter',sans-serif;font-size:12px;padding:7px;outline:none;resize:vertical;min-height:54px;" id="cmt-edit-ta-${c.id}"></textarea>
<div style="display:flex;gap:5px;">
<button class="btn btn-primary" style="font-size:11px;padding:4px 10px;" onclick="saveEditComment(${c.id},'${tid}')">Сохранить</button>
<button class="btn btn-default" style="font-size:11px;padding:4px 10px;" onclick="cancelEditComment(${c.id})">Отмена</button>
</div>
</div>
</div>`;
}).join('')}
</div>`;
}
async function loadAndRenderHistory(idx,containerId){
const {task}=allData[idx];
const tid=String(task.id);
const res=await apiGet(`/api/tasks/history/${tid}`);
if(currentModalIdx!==idx||currentTab!=='history') return;
const el=document.getElementById(containerId);
if(!el) return;
const entries=res?.entries||[];
if(!entries.length){el.innerHTML=`<div style="padding:20px 0;text-align:center;color:var(--text3);font-size:12px;">История пуста</div>`;return;}
const rawDesc=task.description||task.descriptionhtml||task.body||task.text||'';
let db='';
if(rawDesc) db=`<div class="history-entry"><div class="sp-section-title" style="margin-bottom:5px;">Описание</div><div class="he-text">${sanitizeHtml(rawDesc)}</div></div>`;
const items=entries.map(entry=>{
const staff=entry.usertype===10;
const evHtml=(entry.events?.data||[]).map(ev=>{
const tn=EVENT_TYPE_NAMES[ev.type]||`тип ${ev.type}`;
const isCmt=COMMENT_TYPES.has(ev.type);
let body='';
if(isCmt&&ev.stringvalue) body=`<div class="he-text" style="margin-top:3px;">${sanitizeHtml(ev.stringvalue)}</div>`;
else if(ev.type===10&&ev.entitylanguagevalue) body=`<div class="he-pill">«${esc(ev.entitylanguagevalue.valueold?.name?.ru||'?')}» → «${esc(ev.entitylanguagevalue.valuenew?.name?.ru||'?')}»</div>`;
else if(ev.entityvalue) body=`<div class="he-pill">«${esc(ev.entityvalue.valueold?.name||'?')}» → «${esc(ev.entityvalue.valuenew?.name||'?')}»</div>`;
return`<div style="margin-bottom:3px;"><div class="he-type ${isCmt?'comment':''}">${esc(tn)}</div>${body}</div>`;
}).join('');
return`<div class="history-entry">
<div class="he-header">
<div class="client-avatar" style="background:${avatarColor(entry.username)};color:#fff;width:22px;height:22px;font-size:8px;flex-shrink:0;">${avatarInitials(entry.username)}</div>
<span class="he-author ${staff?'staff':'client'}">${esc(abbrevName(entry.username))}</span>
<span class="he-time">${new Date(entry.eventat).toLocaleString('ru-RU')} · ${timeAgo(new Date(entry.eventat))}</span>
</div>${evHtml}
</div>`;
}).join('');
el.innerHTML=`${db}<div style="font-size:10px;color:var(--text3);padding:4px 0 8px;">Событий: ${entries.length}</div>${items}`;
}
// ── Submit comment ────────────────────────────────────────────────
async function submitComment(){
if(currentModalIdx===null) return;
const ta=document.getElementById('composer-ta');
const text=ta?.value?.trim();
if(!text) return;
const tid=String(allData[currentModalIdx].task.id);
ta.disabled=true;
const r=await apiPost('/api/comments',{task_id:tid,text});
if(r?.id){
if(!commentsCache[tid]) commentsCache[tid]=[];
commentsCache[tid].push(r);
if(typeof commentCounts==='object') commentCounts[tid]=commentsCache[tid].length;
ta.value='';
if(currentTab==='comments'){
const sb=document.getElementById('sp-body');
if(sb) sb.innerHTML=renderCommentsHtml(tid,commentsCache[tid]);
}
const dt=document.getElementById('dtab-comments');
if(dt) dt.textContent=`💬 Наши (${commentsCache[tid].length})`;
renderTable();
} else showToast('❌ Ошибка отправки','warn');
ta.disabled=false;
}
// alias
function submitCommentFromModal(){submitComment();}
function insertAtSymbol(){
const ta=document.getElementById('composer-ta');
if(!ta) return;
const p=ta.selectionStart;
ta.value=ta.value.slice(0,p)+'@'+ta.value.slice(p);
ta.selectionStart=ta.selectionEnd=p+1;
ta.focus(); onCommentInput(ta);
}
// ── Note editor ───────────────────────────────────────────────────
function openNoteEditor(idx,e){
e&&e.stopPropagation();
openNoteIdx=openNoteIdx===idx?null:idx;
if(currentModalIdx===idx) renderSidePanel(idx);
else renderTable();
}
function closeNoteEditor(){
openNoteIdx=null;
if(currentModalIdx!==null) renderSidePanel(currentModalIdx);
else renderTable();
}
async function saveNoteAndClose(idx,e){
e&&e.stopPropagation();
const ta=document.getElementById('detail-note-ta');
const tid=String(allData[idx].task.id);
const text=ta?ta.value:'';
if(text.trim()) notesCache[tid]={text:text.trim(),by:currentUser.username,at:new Date().toISOString()};
else delete notesCache[tid];
openNoteIdx=null;
if(currentModalIdx===idx) renderSidePanel(idx);
renderTable();
const r=await apiPost('/api/notes',{task_id:tid,text});
if(r&&!r.ok&&r.error) showToast('❌ '+r.error,'warn');
}
// ── Settings ──────────────────────────────────────────────────────
function openSettingsModal(){document.getElementById('settings-modal')?.classList.add('open');}
function closeSettingsModal(){document.getElementById('settings-modal')?.classList.remove('open');}
// ── SSE refresh ───────────────────────────────────────────────────
function refreshDetailIfOpen(tid){
if(currentModalIdx===null) return;
if(String(allData[currentModalIdx].task.id)!==String(tid)) return;
renderSidePanel(currentModalIdx);
if(currentTab==='comments'){
const sb=document.getElementById('sp-body');
if(sb) sb.innerHTML=renderCommentsHtml(tid,commentsCache[tid]||[]);
}
}
// Инициализация динамических чипов и bulk-select при загрузке DOM
document.addEventListener('DOMContentLoaded', () => {
initMarkChips();
syncChipDropdownItems();
// Close chip dropdown on outside click
document.addEventListener('click', e => {
if (!e.target.closest('#chip-dropdown-wrap')) {
document.getElementById('chip-dropdown-panel')?.classList.remove('chip-dd-open');
}
});
});