From eda3ce742b10080c0b71cf716c6cf377d01a2593 Mon Sep 17 00:00:00 2001 From: overthelex Date: Thu, 2 Jul 2026 15:44:06 +0300 Subject: [PATCH] feat(contacts): resizable table columns + tag filter dropdown - Contacts table columns are now drag-resizable (widths persist in localStorage via a colgroup + per-header resizer handle). - Tag filter is a dropdown populated from existing tags instead of a free text input; new GET /api/clients/tags returns distinct tags with counts. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/core/clients.ts | 12 ++++++++ src/web/api.ts | 10 +++++++ src/web/public/index.html | 60 ++++++++++++++++++++++++++++++++------- 3 files changed, 72 insertions(+), 10 deletions(-) diff --git a/src/core/clients.ts b/src/core/clients.ts index be40550..4cfb77b 100644 --- a/src/core/clients.ts +++ b/src/core/clients.ts @@ -295,6 +295,18 @@ export async function listClients(opts: ListClientsOptions): Promise> { + const rows = await sql` + SELECT trim(tg) AS tag, count(*)::int AS count + FROM email_clients, unnest(string_to_array(tags, ',')) AS tg + WHERE trim(tg) <> '' + GROUP BY trim(tg) + ORDER BY count DESC, tag ASC + `; + return rows.map((r: any) => ({ tag: r.tag, count: r.count })); +} + export async function clientStats(): Promise< Array<{ segment: string; status: string; count: number }> > { diff --git a/src/web/api.ts b/src/web/api.ts index 5edfa60..4da37f7 100644 --- a/src/web/api.ts +++ b/src/web/api.ts @@ -13,6 +13,7 @@ import { importClientsFromOpendata, listClients, clientStats, + listClientTags, getWarmupStatus, validateNewClients, syncSuppressionList, @@ -886,6 +887,15 @@ apiRouter.get("/clients/stats", async (_req, res) => { } }); +apiRouter.get("/clients/tags", async (_req, res) => { + try { + const tags = await listClientTags(); + res.json({ items: tags }); + } catch (err: any) { + res.status(500).json({ error: err.message }); + } +}); + apiRouter.get("/clients/warmup", async (_req, res) => { try { const status = await getWarmupStatus(); diff --git a/src/web/public/index.html b/src/web/public/index.html index ade39a1..ad811f6 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -141,6 +141,9 @@ .tbl .trunc { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .tbl .dim { color: var(--text3); } .tbl .msg-preview { max-width: 400px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text2); } + /* Resizable table columns */ + .col-resizer { position: absolute; top: 0; right: 0; width: 7px; height: 100%; cursor: col-resize; user-select: none; z-index: 2; } + .col-resizer:hover, .col-resizer.dragging { background: var(--amber); } /* ── TAGS & BADGES ── */ .tag { @@ -2646,12 +2649,22 @@
-
+
`; - await Promise.all([loadClientsStats(), loadClientsWarmup(), loadClients()]); + await Promise.all([loadClientsStats(), loadClientsWarmup(), loadClientTags(), loadClients()]); +} + +async function loadClientTags(){ + const sel=$('cl-f-tag'); if(!sel)return; + const cur=sel.value; + const d=await api('/clients/tags'); + const items=(d&&d.items)||[]; + sel.innerHTML=``+ + items.map(x=>``).join(''); + if(cur) sel.value=cur; } async function loadClientsStats(){ @@ -2727,23 +2740,50 @@ box.innerHTML=`
${items.length} ${t('cl_shown')}
- - - - +
${t('cl_col_name')}${t('cl_col_email')}${t('cl_col_org')}${t('cl_col_region')}${t('cl_col_segment')}${t('cl_col_status')}${t('cl_col_last')}
+ + + + + ${items.length?items.map(c=>` - + - - + + - + `).join(''):``}
${t('cl_col_name')}${t('cl_col_email')}${t('cl_col_org')}${t('cl_col_region')}${t('cl_col_segment')}${t('cl_col_status')}${t('cl_col_last')}
${esc(c.name)||'—'}${esc(c.name)||'—'} ${esc(c.email)}${esc(c.org)||'—'}${esc(c.region)||'—'}${esc(c.org)||'—'}${esc(c.region)||'—'} ${clSegLabel(c.segment)} ${clStatusTag(c.status)}${c.lastContactedAt?fmtFull(c.lastContactedAt):t('cl_never')}${c.lastContactedAt?fmtFull(c.lastContactedAt):t('cl_never')}
${t('cl_no_contacts')}
`; + clInitResize(); +} + +// Draggable column widths for the contacts table (persisted in localStorage). +function clInitResize(){ + const table=document.querySelector('#cl-tbl table.cltbl'); if(!table)return; + const cols=table.querySelectorAll('colgroup col'); + let saved={}; try{ saved=JSON.parse(localStorage.getItem('cl_colw')||'{}'); }catch(e){} + cols.forEach((col,i)=>{ if(saved[i]) col.style.width=saved[i]+'px'; }); + table.querySelectorAll('.col-resizer').forEach(h=>{ + h.addEventListener('mousedown',e=>{ + e.preventDefault(); e.stopPropagation(); + const i=+h.dataset.c, col=cols[i]; + const startX=e.pageX, startW=col.getBoundingClientRect().width; + h.classList.add('dragging'); document.body.style.cursor='col-resize'; document.body.style.userSelect='none'; + function mm(ev){ col.style.width=Math.max(48,Math.round(startW+(ev.pageX-startX)))+'px'; } + function mu(){ + document.removeEventListener('mousemove',mm); document.removeEventListener('mouseup',mu); + h.classList.remove('dragging'); document.body.style.cursor=''; document.body.style.userSelect=''; + let s={}; try{ s=JSON.parse(localStorage.getItem('cl_colw')||'{}'); }catch(e){} + s[i]=Math.round(col.getBoundingClientRect().width); localStorage.setItem('cl_colw',JSON.stringify(s)); + } + document.addEventListener('mousemove',mm); document.addEventListener('mouseup',mu); + }); + }); } /* ── Contacts: actions ── */