1208 lines
54 KiB
JavaScript
1208 lines
54 KiB
JavaScript
// ── 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'
|
||
|
||
const ADV_FILTER_CONFIG = {
|
||
'filter-who-select': {
|
||
panelId: 'filter-who-panel',
|
||
wrapId: 'filter-who-wrap',
|
||
labelId: 'filter-who-label',
|
||
allLabel: 'Кто ответил: все',
|
||
shortLabel: 'Кто ответил',
|
||
},
|
||
'filter-executor-select': {
|
||
panelId: 'filter-executor-panel',
|
||
wrapId: 'filter-executor-wrap',
|
||
labelId: 'filter-executor-label',
|
||
allLabel: 'Исполнители: все',
|
||
shortLabel: 'Исполнители',
|
||
},
|
||
'filter-object-select': {
|
||
panelId: 'filter-object-panel',
|
||
wrapId: 'filter-object-wrap',
|
||
labelId: 'filter-object-label',
|
||
allLabel: 'Объекты: все',
|
||
shortLabel: 'Объекты',
|
||
},
|
||
'filter-intra-select': {
|
||
panelId: 'filter-intra-panel',
|
||
wrapId: 'filter-intra-wrap',
|
||
labelId: 'filter-intra-label',
|
||
allLabel: 'Статус Intra: все',
|
||
shortLabel: 'Статус Intra',
|
||
},
|
||
'filter-internal-select': {
|
||
panelId: 'filter-internal-panel',
|
||
wrapId: 'filter-internal-wrap',
|
||
labelId: 'filter-internal-label',
|
||
allLabel: 'Внутр. статусы: все',
|
||
shortLabel: 'Внутр. статусы',
|
||
},
|
||
};
|
||
|
||
function getSelectedSet(selectId){
|
||
const el=document.getElementById(selectId);
|
||
if(!el) return new Set();
|
||
return new Set([...el.selectedOptions].map(o=>String(o.value)).filter(Boolean));
|
||
}
|
||
|
||
function fillMultiSelect(selectId, items, selectedSet, emptyLabel){
|
||
const el=document.getElementById(selectId);
|
||
if(!el) return;
|
||
if(!items.length){
|
||
el.innerHTML=`<option value="" disabled>${emptyLabel}</option>`;
|
||
return;
|
||
}
|
||
const html=items.map(({value,label})=>`<option value="${esc(value)}">${esc(label)}</option>`).join('');
|
||
el.innerHTML=html;
|
||
[...el.options].forEach((opt)=>{ opt.selected=selectedSet.has(String(opt.value)); });
|
||
}
|
||
|
||
function updateAdvancedFilterLabel(selectId){
|
||
const cfg=ADV_FILTER_CONFIG[selectId];
|
||
if(!cfg) return;
|
||
const el=document.getElementById(selectId);
|
||
const lbl=document.getElementById(cfg.labelId);
|
||
if(!el||!lbl) return;
|
||
const selected=[...el.selectedOptions].filter((o)=>!o.disabled&&String(o.value).trim()!=='');
|
||
if(!selected.length){
|
||
lbl.textContent=cfg.allLabel;
|
||
return;
|
||
}
|
||
if(selected.length===1){
|
||
lbl.textContent=`${cfg.shortLabel}: ${selected[0].textContent}`;
|
||
return;
|
||
}
|
||
lbl.textContent=`${cfg.shortLabel}: ${selected.length}`;
|
||
}
|
||
|
||
function getIntraStatusFilterColor(label){
|
||
const lc=String(label||'').toLowerCase();
|
||
if(lc.includes('открыт')||lc.includes('нов')||lc.includes('назнач')) return 'var(--blue)';
|
||
if(lc.includes('работ')||lc.includes('ожид')||lc.includes('progress')) return 'var(--orange)';
|
||
if(lc.includes('выполн')||lc.includes('закрыт')||lc.includes('реш')) return 'var(--green)';
|
||
if(lc.includes('hold')||lc.includes('пауза')||lc.includes('приост')) return '#faad14';
|
||
return 'var(--text3)';
|
||
}
|
||
|
||
function getAdvancedFilterOptionColor(selectId,opt){
|
||
const value=String(opt?.value||'');
|
||
const label=String(opt?.textContent||'');
|
||
if(selectId==='filter-who-select'){
|
||
if(value==='internal') return 'var(--green)';
|
||
if(value==='external') return 'var(--red)';
|
||
return 'var(--text3)';
|
||
}
|
||
if(selectId==='filter-internal-select'){
|
||
const m=MARKS_CONFIG.find((x)=>String(x.key)===value);
|
||
return m?.colorDot||m?.chipDotColor||m?.ispColor||'var(--text3)';
|
||
}
|
||
if(selectId==='filter-intra-select') return getIntraStatusFilterColor(label);
|
||
return avatarColor(label||value);
|
||
}
|
||
|
||
function setAdvancedFilterSelection(selectId,selected){
|
||
const sel=document.getElementById(selectId);
|
||
if(!sel) return;
|
||
[...sel.options].forEach((opt)=>{
|
||
if(opt.disabled) return;
|
||
if(String(opt.value).trim()==='') return;
|
||
opt.selected=!!selected;
|
||
});
|
||
updateAdvancedFilterLabel(selectId);
|
||
buildAdvancedFilterPanel(selectId);
|
||
renderTable();
|
||
}
|
||
|
||
function buildAdvancedFilterPanel(selectId){
|
||
const cfg=ADV_FILTER_CONFIG[selectId];
|
||
if(!cfg) return;
|
||
const sel=document.getElementById(selectId);
|
||
const panel=document.getElementById(cfg.panelId);
|
||
if(!sel||!panel) return;
|
||
panel.innerHTML='';
|
||
const opts=[...sel.options].filter((o)=>!o.disabled&&String(o.value).trim()!=='');
|
||
if(!opts.length){
|
||
const empty=document.createElement('div');
|
||
empty.className='adv-filter-empty';
|
||
empty.textContent='Нет значений';
|
||
panel.appendChild(empty);
|
||
return;
|
||
}
|
||
|
||
const selectedCount=opts.filter((o)=>o.selected).length;
|
||
const actions=document.createElement('div');
|
||
actions.className='adv-filter-actions';
|
||
|
||
const btnAll=document.createElement('button');
|
||
btnAll.type='button';
|
||
btnAll.className='adv-filter-action-btn';
|
||
btnAll.textContent='Выбрать все';
|
||
btnAll.disabled=selectedCount===opts.length;
|
||
btnAll.addEventListener('click',(e)=>{
|
||
e.stopPropagation();
|
||
setAdvancedFilterSelection(selectId,true);
|
||
});
|
||
|
||
const btnNone=document.createElement('button');
|
||
btnNone.type='button';
|
||
btnNone.className='adv-filter-action-btn';
|
||
btnNone.textContent='Снять все';
|
||
btnNone.disabled=selectedCount===0;
|
||
btnNone.addEventListener('click',(e)=>{
|
||
e.stopPropagation();
|
||
setAdvancedFilterSelection(selectId,false);
|
||
});
|
||
|
||
actions.appendChild(btnAll);
|
||
actions.appendChild(btnNone);
|
||
panel.appendChild(actions);
|
||
|
||
const frag=document.createDocumentFragment();
|
||
opts.forEach((opt)=>{
|
||
const item=document.createElement('label');
|
||
item.className='adv-filter-item';
|
||
|
||
const cb=document.createElement('input');
|
||
cb.type='checkbox';
|
||
cb.checked=!!opt.selected;
|
||
cb.addEventListener('change',()=>{
|
||
opt.selected=cb.checked;
|
||
updateAdvancedFilterLabel(selectId);
|
||
renderTable();
|
||
});
|
||
|
||
const dot=document.createElement('span');
|
||
dot.className='adv-filter-dot';
|
||
dot.style.background=getAdvancedFilterOptionColor(selectId,opt);
|
||
|
||
const text=document.createElement('span');
|
||
text.className='adv-filter-text';
|
||
text.textContent=opt.textContent;
|
||
|
||
item.appendChild(cb);
|
||
item.appendChild(dot);
|
||
item.appendChild(text);
|
||
frag.appendChild(item);
|
||
});
|
||
panel.appendChild(frag);
|
||
}
|
||
|
||
function closeAdvancedFilterDropdowns(exceptSelectId=''){
|
||
Object.entries(ADV_FILTER_CONFIG).forEach(([selectId,cfg])=>{
|
||
if(exceptSelectId&&selectId===exceptSelectId) return;
|
||
document.getElementById(cfg.panelId)?.classList.remove('open');
|
||
document.getElementById(cfg.wrapId)?.classList.remove('open');
|
||
});
|
||
}
|
||
|
||
function toggleAdvancedFilterDropdown(e,selectId){
|
||
e?.stopPropagation();
|
||
const cfg=ADV_FILTER_CONFIG[selectId];
|
||
if(!cfg) return;
|
||
const panel=document.getElementById(cfg.panelId);
|
||
const wrap=document.getElementById(cfg.wrapId);
|
||
if(!panel||!wrap) return;
|
||
const willOpen=!panel.classList.contains('open');
|
||
closeAdvancedFilterDropdowns(selectId);
|
||
if(!willOpen){
|
||
panel.classList.remove('open');
|
||
wrap.classList.remove('open');
|
||
return;
|
||
}
|
||
buildAdvancedFilterPanel(selectId);
|
||
panel.classList.add('open');
|
||
wrap.classList.add('open');
|
||
}
|
||
|
||
function syncAdvancedFiltersUi(){
|
||
Object.keys(ADV_FILTER_CONFIG).forEach((selectId)=>{
|
||
updateAdvancedFilterLabel(selectId);
|
||
const cfg=ADV_FILTER_CONFIG[selectId];
|
||
const panel=document.getElementById(cfg.panelId);
|
||
if(panel?.classList.contains('open')) buildAdvancedFilterPanel(selectId);
|
||
});
|
||
}
|
||
|
||
function refreshAdvancedFilters(){
|
||
const selectedWho=getSelectedSet('filter-who-select');
|
||
const selectedExecutors=getSelectedSet('filter-executor-select');
|
||
const selectedObjects=getSelectedSet('filter-object-select');
|
||
const selectedIntra=getSelectedSet('filter-intra-select');
|
||
const selectedInternal=getSelectedSet('filter-internal-select');
|
||
|
||
const execMap=new Map();
|
||
const objectMap=new Map();
|
||
const intraMap=new Map();
|
||
allData.forEach(({task})=>{
|
||
const execId=String(task.executor||'').trim();
|
||
if(execId){
|
||
const execName=String(task._executorname||task.executor||execId).trim();
|
||
execMap.set(execId, execName);
|
||
}
|
||
const objectName=String(task._clientname||task.clientname||task.organization||'').trim();
|
||
if(objectName) objectMap.set(objectName, objectName);
|
||
const intraId=String(task.statusid||task.status||'').trim();
|
||
const intraName=String(task._statusname||task.statusname||intraId).trim();
|
||
if(intraId) intraMap.set(intraId, intraName||intraId);
|
||
});
|
||
|
||
const execItems=[...execMap.entries()].map(([value,label])=>({value,label})).sort((a,b)=>a.label.localeCompare(b.label,'ru'));
|
||
const objectItems=[...objectMap.entries()].map(([value,label])=>({value,label})).sort((a,b)=>a.label.localeCompare(b.label,'ru'));
|
||
const intraItems=[...intraMap.entries()].map(([value,label])=>({value,label:`#${value} ${label}`}));
|
||
const whoItems=[
|
||
{ value:'internal', label:'Сотрудник' },
|
||
{ value:'external', label:'Клиент' },
|
||
{ value:'nocomment', label:'Нет ответа' },
|
||
];
|
||
const internalItems=MARKS_CONFIG.map((m)=>({value:m.key,label:m.label}));
|
||
|
||
fillMultiSelect('filter-who-select', whoItems, selectedWho, 'No values');
|
||
fillMultiSelect('filter-executor-select', execItems, selectedExecutors, 'No executors');
|
||
fillMultiSelect('filter-object-select', objectItems, selectedObjects, 'No objects');
|
||
fillMultiSelect('filter-intra-select', intraItems, selectedIntra, 'No Intra statuses');
|
||
fillMultiSelect('filter-internal-select', internalItems, selectedInternal, 'No statuses');
|
||
syncAdvancedFiltersUi();
|
||
}
|
||
|
||
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();
|
||
}
|
||
|
||
// ── 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){
|
||
sel.innerHTML='<option value="" disabled selected>Set status</option><option value="clear">Clear status</option>';
|
||
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);
|
||
});
|
||
}
|
||
if(typeof refreshAdvancedFilters==='function') refreshAdvancedFilters();
|
||
}
|
||
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 statusBadgeStyle(m){
|
||
const bg=m.colorBg||'var(--surface2)';
|
||
const border=m.colorBorder||'var(--border2)';
|
||
const text=m.colorText||'var(--text2)';
|
||
const dot=m.colorDot||m.chipDotColor||m.ispColor||text;
|
||
return {
|
||
outer:`background:${bg};border-color:${border};color:${text};`,
|
||
dot:`background:${dot};`,
|
||
};
|
||
}
|
||
|
||
function internalStatusBadge(tid){
|
||
const m=MARKS_CONFIG.find((status)=>isMark(tid,status.key));
|
||
if(m){
|
||
const style=statusBadgeStyle(m);
|
||
return `<span class="status-badge ${m.badgeClass||'sb-custom'}" style="${style.outer}"><span class="dot" style="${style.dot}"></span>${esc(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) {
|
||
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 = String(name).toLowerCase();
|
||
let cls = 'sb-intra-other';
|
||
if (lc.includes('открыт') || lc.includes('new')) 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('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;
|
||
const selectedWho=getSelectedSet('filter-who-select');
|
||
const selectedExecutors=getSelectedSet('filter-executor-select');
|
||
const selectedObjects=getSelectedSet('filter-object-select');
|
||
const selectedIntra=getSelectedSet('filter-intra-select');
|
||
const selectedInternal=getSelectedSet('filter-internal-select');
|
||
const mentionTaskIds=mentionedFilterOn?new Set(mentionsData.map((m)=>String(m.task_id))):null;
|
||
const seenMap=loadSeen();
|
||
const isUnreadFast=(task)=>!seenMap[task.id]||seenMap[task.id]!==task.updatedat;
|
||
|
||
filteredData=allData.filter(item=>{
|
||
const tid=String(item.task.id);
|
||
const marked=MARKS_CONFIG.some(m=>isMark(tid,m.key));
|
||
const activeInternal=MARKS_CONFIG.find((m)=>isMark(tid,m.key))?.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&&mentionTaskIds&&!mentionTaskIds.has(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(selectedExecutors.size&& !selectedExecutors.has(String(item.task.executor||''))) return false;
|
||
if(selectedObjects.size){
|
||
const objectName=String(item.task._clientname||item.task.clientname||item.task.organization||'').trim();
|
||
if(!objectName || !selectedObjects.has(objectName)) return false;
|
||
}
|
||
if(selectedIntra.size){
|
||
const intraId=String(item.task.statusid||item.task.status||'').trim();
|
||
if(!intraId || !selectedIntra.has(intraId)) return false;
|
||
}
|
||
if(selectedInternal.size){
|
||
if(!activeInternal || !selectedInternal.has(activeInternal)) return false;
|
||
}
|
||
const authorType=getAuthorType(item);
|
||
if(statFilter!=='all'){
|
||
if(authorType!==statFilter) return false;
|
||
}else if(selectedWho.size&& !selectedWho.has(authorType)){
|
||
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 markCounts=Object.fromEntries(MARKS_CONFIG.map((m)=>[m.key,0]));
|
||
let baseLen=0;
|
||
let baseUnmarked=0;
|
||
let baseExternal=0;
|
||
let baseInternal=0;
|
||
let baseNoComment=0;
|
||
let unreadTotal=0;
|
||
let tabOpen=0;
|
||
let tabDone=0;
|
||
let tabEmpty=0;
|
||
|
||
allData.forEach((item)=>{
|
||
if(isUnreadFast(item.task)) unreadTotal++;
|
||
if(isTaskOpen(item)) tabOpen++;
|
||
if(isTaskDone(item)) tabDone++;
|
||
if(isTaskEmpty(item)) tabEmpty++;
|
||
|
||
if(!taskMatchesDate(item.task)) return;
|
||
baseLen++;
|
||
|
||
const author=getAuthorType(item);
|
||
if(author==='external') baseExternal++;
|
||
else if(author==='internal') baseInternal++;
|
||
else if(author==='nocomment') baseNoComment++;
|
||
|
||
const tid=String(item.task.id);
|
||
const markedKey=MARKS_CONFIG.find((m)=>isMark(tid,m.key))?.key||'';
|
||
if(markedKey) markCounts[markedKey]=(markCounts[markedKey]||0)+1;
|
||
else baseUnmarked++;
|
||
});
|
||
|
||
const g=id=>document.getElementById(id);
|
||
const sv=(id,v)=>{const e=g(id);if(e)e.textContent=v;};
|
||
sv('chip-all-cnt', baseLen);
|
||
MARKS_CONFIG.forEach((m)=>{ sv(`chip-${m.key}-cnt`, markCounts[m.key]||0); });
|
||
sv('chip-unmarked-cnt', baseUnmarked);
|
||
sv('stat-total', baseLen);
|
||
sv('stat-external', baseExternal);
|
||
sv('stat-internal', baseInternal);
|
||
sv('stat-nocomment', baseNoComment);
|
||
sv('stat-unread', unreadTotal);
|
||
sv('nav-badge-tasks', filteredData.length);
|
||
sv('topbar-sub', `\u00b7 ${filteredData.length} \u0437\u0430\u044f\u0432\u043e\u043a`);
|
||
sv('total-loaded', filteredData.length);
|
||
sv('total-count', allData.length);
|
||
sv('tf-info', `${filteredData.length} \u0438\u0437 ${allData.length}`);
|
||
// Tab counts (using allData for totals regardless of chip filter)
|
||
sv('tcnt-all', allData.length);
|
||
sv('tcnt-open', tabOpen);
|
||
sv('tcnt-done', tabDone);
|
||
sv('tcnt-empty', tabEmpty);
|
||
|
||
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 idxMap=new Map();
|
||
allData.forEach((item,idx)=>idxMap.set(item,idx));
|
||
|
||
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=idxMap.get(item);
|
||
const unread=isUnreadFast(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 statusKeys=MARKS_CONFIG.map((m)=>m.key);
|
||
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=statusKeys.find(m=>marksCache[tid][m]?.active);
|
||
statusKeys.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{
|
||
statusKeys.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');
|
||
if(typeof openSettingsTab==='function') openSettingsTab('api');
|
||
if(currentUser?.is_admin){
|
||
if(typeof loadIntraStatusDictionary==='function') loadIntraStatusDictionary();
|
||
if(typeof loadInternalStatusesAdmin==='function') loadInternalStatusesAdmin();
|
||
}
|
||
}
|
||
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();
|
||
syncAdvancedFiltersUi();
|
||
// 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');
|
||
}
|
||
if (!e.target.closest('.adv-filter-wrap')) {
|
||
closeAdvancedFilterDropdowns();
|
||
}
|
||
});
|
||
});
|