From 13504627c4ed8e50dbd8c561b4e66b707e291ba8 Mon Sep 17 00:00:00 2001 From: dv Date: Thu, 19 Mar 2026 11:59:22 +0300 Subject: [PATCH] vibe 3 --- public/css/app.css | 89 +++++++++++++++++- public/index.html | 54 +++++++++-- public/js/auth.js | 73 +++++++++----- public/js/diff.js | 1 + public/js/ui.js | 230 +++++++++++++++++++++++++++++++++++++++------ 5 files changed, 381 insertions(+), 66 deletions(-) diff --git a/public/css/app.css b/public/css/app.css index 9d7db68..cdb461a 100644 --- a/public/css/app.css +++ b/public/css/app.css @@ -1310,12 +1310,91 @@ html.dark .stats-card { background:var(--surface); } border-top:1px dashed var(--border); margin-top:-4px; padding-top:10px; + align-items:stretch; } -.filter-multi{ - min-width:160px; - min-height:74px; - background-image:none; - padding-right:10px; +.filter-native-hidden{display:none!important;} + +.adv-filter-wrap{ + position:relative; + min-width:180px; + max-width:260px; + flex:1 1 180px; +} +.adv-filter-btn{ + width:100%; + display:flex; + align-items:center; + justify-content:space-between; + gap:8px; + padding:7px 10px; + border:1px solid var(--border); + border-radius:var(--radius-sm); + background:var(--surface2); + color:var(--text2); + font-family:inherit; + font-size:12px; + cursor:pointer; + transition:border-color .15s,color .15s,background .15s; +} +.adv-filter-btn:hover{ + border-color:var(--accent); + color:var(--accent); +} +.adv-filter-btn span{ + min-width:0; + overflow:hidden; + text-overflow:ellipsis; + white-space:nowrap; + text-align:left; +} +.adv-filter-wrap.open .adv-filter-btn{ + border-color:var(--accent); + color:var(--accent); + background:var(--accent-light); +} +.adv-filter-panel{ + display:none; + position:absolute; + top:calc(100% + 4px); + left:0; + right:0; + z-index:320; + max-height:260px; + overflow:auto; + background:var(--surface); + border:1px solid var(--border); + border-radius:var(--radius); + box-shadow:0 8px 24px rgba(0,0,0,.13); + padding:6px; +} +.adv-filter-panel.open{display:block;} +.adv-filter-item{ + display:flex; + align-items:flex-start; + gap:8px; + padding:6px 7px; + border-radius:6px; + font-size:12px; + color:var(--text2); + cursor:pointer; + user-select:none; +} +.adv-filter-item:hover{background:var(--surface2);} +.adv-filter-item input{ + margin-top:2px; + accent-color:var(--accent); + cursor:pointer; +} +.adv-filter-item span{ + flex:1; + min-width:0; + line-height:1.35; + word-break:break-word; +} +.adv-filter-empty{ + padding:8px 10px; + color:var(--text3); + font-size:12px; } /* ── Chip Dropdown ────────────────────────────────────── */ diff --git a/public/index.html b/public/index.html index d29a0b5..0765aba 100644 --- a/public/index.html +++ b/public/index.html @@ -298,16 +298,50 @@
- - - - - +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
diff --git a/public/js/auth.js b/public/js/auth.js index 5df0e30..37c271f 100644 --- a/public/js/auth.js +++ b/public/js/auth.js @@ -60,6 +60,7 @@ function showApp() { let lastLoadedSettings = {}; let internalStatusesAdminCache = []; +let missingCommentsFetchInFlight = false; async function init() { await loadInternalStatusesConfig(false, true); @@ -137,6 +138,7 @@ async function loadTasksFromServer(silent = false) { } async function fetchMissingComments() { + if (missingCommentsFetchInFlight) return; const toFetch = allData.filter((i) => { if (i.lastComment) return false; const tid = String(i.task.id); @@ -145,32 +147,55 @@ async function fetchMissingComments() { }); if (!toFetch.length) return; - for (const item of toFetch) { - const tid = String(item.task.id); - const upd = encodeURIComponent(item.task.updatedat || ''); - let lc = null; - try { - const res = await apiGet(`/api/tasks/lastcomment/${tid}?upd=${upd}`); - if (res?.entries?.length) { - const entry = res.entries[0]; - const ev = (entry.events?.data || []).find((e) => COMMENT_TYPES.has(e.type)); - if (ev) { - lc = { - username: entry.username, - userid: entry.userid, - usertype: entry.usertype, - eventat: entry.eventat, - text: ev.stringvalue || '', - isPrivate: ev.type === 55, - eventType: ev.type, - }; - } + missingCommentsFetchInFlight = true; + try { + const CONCURRENCY = 8; + let cursor = 0; + let loaded = 0; + + async function worker() { + while (true) { + const idx = cursor; + cursor += 1; + if (idx >= toFetch.length) return; + + const item = toFetch[idx]; + const tid = String(item.task.id); + const upd = encodeURIComponent(item.task.updatedat || ''); + let lc = null; + + try { + const res = await apiGet(`/api/tasks/lastcomment/${tid}?upd=${upd}`); + if (res?.entries?.length) { + const entry = res.entries[0]; + const ev = (entry.events?.data || []).find((e) => COMMENT_TYPES.has(e.type)); + if (ev) { + lc = { + username: entry.username, + userid: entry.userid, + usertype: entry.usertype, + eventat: entry.eventat, + text: ev.stringvalue || '', + isPrivate: ev.type === 55, + eventType: ev.type, + }; + } + } + } catch {} + + item.lastComment = lc; + forcedCommentCache.set(tid, { updatedat: item.task.updatedat, lastComment: lc }); + loaded += 1; + if (loaded % 25 === 0 && typeof renderTable === 'function') renderTable(true); } - } catch {} - item.lastComment = lc; - forcedCommentCache.set(tid, { updatedat: item.task.updatedat, lastComment: lc }); + } + + const workers = Array.from({ length: Math.min(CONCURRENCY, toFetch.length) }, () => worker()); + await Promise.all(workers); + } finally { + missingCommentsFetchInFlight = false; + if (typeof renderTable === 'function') renderTable(true); } - renderTable(); } async function loadTasks() { diff --git a/public/js/diff.js b/public/js/diff.js index 03cbd34..d55bb59 100644 --- a/public/js/diff.js +++ b/public/js/diff.js @@ -147,6 +147,7 @@ function applyTasksDiff(newRawTasks, silent = false) { document.getElementById('update-banner')?.classList.remove('visible'); if (typeof onTasksLoaded === 'function') onTasksLoaded(); + if (typeof refreshAdvancedFilters === 'function') refreshAdvancedFilters(); if (typeof renderTable === 'function') renderTable(); if (typeof forceComment !== 'undefined' && forceComment) { if (typeof fetchMissingComments === 'function') fetchMissingComments(); diff --git a/public/js/ui.js b/public/js/ui.js index 439cf0e..5fabfb7 100644 --- a/public/js/ui.js +++ b/public/js/ui.js @@ -16,6 +16,44 @@ 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(); @@ -34,10 +72,102 @@ function fillMultiSelect(selectId, items, selectedSet, emptyLabel){ [...el.options].forEach((opt)=>{ opt.selected=selectedSet.has(String(opt.value)); }); } -function refreshAdvancedFilters(){ - const who=document.getElementById('filter-who-select'); - if(who && !who.value) who.value='all'; +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 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 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 text=document.createElement('span'); + text.textContent=opt.textContent; + + item.appendChild(cb); + 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'); @@ -62,12 +192,19 @@ function refreshAdvancedFilters(){ 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) { @@ -362,11 +499,14 @@ function renderTable(keepPage){ if(!keepPage) currentPage = 1; const search =document.getElementById('search-input')?.value?.toLowerCase()||''; const remoteOnly =document.getElementById('opt-remote-only')?.checked; - const whoSelect =document.getElementById('filter-who-select')?.value||'all'; + 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); @@ -374,7 +514,7 @@ function renderTable(keepPage){ 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&&!mentionsData.some(m=>String(m.task_id)===tid)) 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; @@ -390,8 +530,12 @@ function renderTable(keepPage){ if(selectedInternal.size){ if(!activeInternal || !selectedInternal.has(activeInternal)) return false; } - const whoFilter=whoSelect!=='all'?whoSelect:statFilter; - if(whoFilter!=='all'&&getAuthorType(item)!==whoFilter) 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; @@ -414,31 +558,57 @@ function renderTable(keepPage){ } // Counts - const base=allData.filter(i=>taskMatchesDate(i.task)); + 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', 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('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', `· ${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} из ${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', allData.filter(i=>isTaskOpen(i)).length); - sv('tcnt-done', allData.filter(i=>isTaskDone(i)).length); - sv('tcnt-empty', allData.filter(i=>isTaskEmpty(i)).length); + sv('tcnt-open', tabOpen); + sv('tcnt-done', tabDone); + sv('tcnt-empty', tabEmpty); if(!filteredData.length){ g('table-wrap').innerHTML=`
🔍
Заявок не найдено
Измените фильтры
`; @@ -451,6 +621,8 @@ function renderTable(keepPage){ 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' ? ' ↑' : ' ↓') : ' ↕'; @@ -471,8 +643,8 @@ function renderTable(keepPage){ 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 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(); @@ -951,10 +1123,14 @@ function refreshDetailIfOpen(tid){ 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(); + } }); });