Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions src/core/clients.ts
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,18 @@ export async function listClients(opts: ListClientsOptions): Promise<EmailClient
return rows.map(mapClient);
}

// Distinct tags across all clients (tags column is comma-separated), with counts.
export async function listClientTags(): Promise<Array<{ tag: string; count: number }>> {
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 }>
> {
Expand Down
10 changes: 10 additions & 0 deletions src/web/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
importClientsFromOpendata,
listClients,
clientStats,
listClientTags,
getWarmupStatus,
validateNewClients,
syncSuppressionList,
Expand Down Expand Up @@ -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();
Expand Down
60 changes: 50 additions & 10 deletions src/web/public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -2646,12 +2649,22 @@
<div class="field" style="max-width:160px"><label>${t('cl_filter_status')}</label>
<select id="cl-f-status"><option value="">${t('cl_seg_all')}</option>${CL_STATUSES.map(s=>`<option value="${s}">${t('cl_st_'+s)}</option>`).join('')}</select>
</div>
<div class="field" style="max-width:160px"><label>${t('cl_filter_tag')}</label><input type="text" id="cl-f-tag" placeholder="${t('cl_tag_ph')}"></div>
<div class="field" style="max-width:160px"><label>${t('cl_filter_tag')}</label><select id="cl-f-tag"><option value="">${t('cl_seg_all')}</option></select></div>
<div class="field" style="max-width:100px"><label>${t('cl_filter_limit')}</label><input type="number" id="cl-f-lim" value="100"></div>
<div class="field"><label>&nbsp;</label><button class="btn primary" onclick="loadClients()">${t('cl_load')}</button></div>
</div>
<div id="cl-tbl"></div>`;
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=`<option value="">${t('cl_seg_all')}</option>`+
items.map(x=>`<option value="${esc(x.tag)}">${esc(x.tag)} (${x.count})</option>`).join('');
if(cur) sel.value=cur;
}

async function loadClientsStats(){
Expand Down Expand Up @@ -2727,23 +2740,50 @@
box.innerHTML=`
<div style="font-size:11px;color:var(--text3);margin-bottom:8px">${items.length} ${t('cl_shown')}</div>
<div class="card"><div class="card-body flush" style="max-height:60vh;overflow:auto">
<table class="tbl"><thead><tr>
<th>${t('cl_col_name')}</th><th>${t('cl_col_email')}</th><th>${t('cl_col_org')}</th>
<th>${t('cl_col_region')}</th><th>${t('cl_col_segment')}</th><th>${t('cl_col_status')}</th>
<th>${t('cl_col_last')}</th><th></th>
<table class="tbl cltbl" style="table-layout:fixed;width:100%">
<colgroup><col style="width:160px"><col style="width:230px"><col style="width:200px"><col style="width:120px"><col style="width:110px"><col style="width:110px"><col style="width:150px"><col style="width:90px"></colgroup>
<thead><tr>
<th>${t('cl_col_name')}<span class="col-resizer" data-c="0"></span></th><th>${t('cl_col_email')}<span class="col-resizer" data-c="1"></span></th><th>${t('cl_col_org')}<span class="col-resizer" data-c="2"></span></th>
<th>${t('cl_col_region')}<span class="col-resizer" data-c="3"></span></th><th>${t('cl_col_segment')}<span class="col-resizer" data-c="4"></span></th><th>${t('cl_col_status')}<span class="col-resizer" data-c="5"></span></th>
<th>${t('cl_col_last')}<span class="col-resizer" data-c="6"></span></th><th></th>
</tr></thead><tbody>
${items.length?items.map(c=>`<tr>
<td><strong>${esc(c.name)||'—'}</strong></td>
<td class="trunc" title="${esc(c.name)}"><strong>${esc(c.name)||'—'}</strong></td>
<td class="mono trunc" title="${esc(c.email)}">${esc(c.email)}</td>
<td class="nowrap trunc" title="${esc(c.org)}">${esc(c.org)||'—'}</td>
<td class="nowrap dim">${esc(c.region)||'—'}</td>
<td class="trunc" title="${esc(c.org)}">${esc(c.org)||'—'}</td>
<td class="trunc dim" title="${esc(c.region)}">${esc(c.region)||'—'}</td>
<td class="nowrap">${clSegLabel(c.segment)}</td>
<td class="nowrap">${clStatusTag(c.status)}</td>
<td class="mono dim nowrap">${c.lastContactedAt?fmtFull(c.lastContactedAt):t('cl_never')}</td>
<td class="mono dim trunc">${c.lastContactedAt?fmtFull(c.lastContactedAt):t('cl_never')}</td>
<td class="nowrap"><button class="btn sm ghost" onclick="clSendModal(${c.id},'${esc(c.email).replace(/'/g,"\\'")}','${esc(c.name).replace(/'/g,"\\'")}')">${t('cl_send')}</button></td>
</tr>`).join(''):`<tr><td colspan="8" style="color:var(--text3);text-align:center;padding:24px">${t('cl_no_contacts')}</td></tr>`}
</tbody></table>
</div></div>`;
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 ── */
Expand Down
Loading