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
22 changes: 21 additions & 1 deletion src/core/clients.ts
Original file line number Diff line number Diff line change
Expand Up @@ -280,16 +280,36 @@ export interface ListClientsOptions {
segment?: ClientSegment;
status?: string;
tag?: string;
sort?: string;
dir?: "asc" | "desc";
limit: number;
}

// Whitelist of sortable columns (maps UI keys → real column names).
const SORT_COLUMNS: Record<string, string> = {
name: "name",
email: "email",
org: "org",
region: "region",
segment: "segment",
status: "status",
last: "last_contacted_at",
};

export async function listClients(opts: ListClientsOptions): Promise<EmailClient[]> {
// Build a safe ORDER BY from the whitelist; default = most recently imported.
const col = opts.sort ? SORT_COLUMNS[opts.sort] : undefined;
const dir = opts.dir === "asc" ? "ASC" : "DESC";
const orderBy = col
? `ORDER BY ${col} ${dir} NULLS LAST, imported_at DESC`
: `ORDER BY imported_at DESC`;

const rows = await sql`
SELECT * FROM email_clients
WHERE (${opts.segment ?? null}::text IS NULL OR segment = ${opts.segment ?? null})
AND (${opts.status ?? null}::text IS NULL OR status = ${opts.status ?? null})
AND (${opts.tag ?? null}::text IS NULL OR tags LIKE ${"%" + (opts.tag ?? "") + "%"})
ORDER BY imported_at DESC
${sql.unsafe(orderBy)}
LIMIT ${opts.limit}
`;
return rows.map(mapClient);
Expand Down
2 changes: 2 additions & 0 deletions src/web/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -870,6 +870,8 @@ apiRouter.get("/clients", async (req, res) => {
segment: (req.query.segment as ClientSegment) || undefined,
status: (req.query.status as string) || undefined,
tag: (req.query.tag as string) || undefined,
sort: (req.query.sort as string) || undefined,
dir: (req.query.dir as string) === "asc" ? "asc" : req.query.dir === "desc" ? "desc" : undefined,
limit,
});
res.json({ items: clients });
Expand Down
36 changes: 30 additions & 6 deletions src/web/public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -502,7 +502,7 @@
cl_filter_segment:'Segment', cl_filter_status:'Status', cl_filter_tag:'Tag',
cl_filter_limit:'Limit', cl_load:'Load', cl_tag_ph:'Filter tag', cl_shown:'shown',
cl_col_name:'Name', cl_col_email:'Email', cl_col_org:'Org', cl_col_region:'Region',
cl_col_segment:'Segment', cl_col_status:'Status', cl_col_last:'Last Contacted', cl_col_actions:'',
cl_col_segment:'Segment', cl_col_status:'Status', cl_col_last:'Last Contacted', cl_col_actions:'', cl_sort_hint:'Click to sort',
cl_no_contacts:'No contacts found', cl_never:'Never',
cl_import_title:'Import Contacts', cl_import_btn:'Import',
cl_optional:'optional', cl_segment:'Segment',
Expand Down Expand Up @@ -612,7 +612,7 @@
cl_filter_segment:'Сегмент', cl_filter_status:'Статус', cl_filter_tag:'Тег',
cl_filter_limit:'Ліміт', cl_load:'Завантажити', cl_tag_ph:'Фільтр тегів', cl_shown:'показано',
cl_col_name:'Ім\'я', cl_col_email:'Email', cl_col_org:'Організація', cl_col_region:'Регіон',
cl_col_segment:'Сегмент', cl_col_status:'Статус', cl_col_last:'Останній контакт', cl_col_actions:'',
cl_col_segment:'Сегмент', cl_col_status:'Статус', cl_col_last:'Останній контакт', cl_col_actions:'', cl_sort_hint:'Клікніть, щоб сортувати',
cl_no_contacts:'Контактів не знайдено', cl_never:'Ніколи',
cl_import_title:'Імпорт контактів', cl_import_btn:'Імпортувати',
cl_optional:'необов\'язково', cl_segment:'Сегмент',
Expand Down Expand Up @@ -2726,6 +2726,15 @@
</div>`;
}

// Sortable columns for the contacts table (key → header i18n label).
const CL_COLS=[
{key:'name',label:'cl_col_name'},{key:'email',label:'cl_col_email'},{key:'org',label:'cl_col_org'},
{key:'region',label:'cl_col_region'},{key:'segment',label:'cl_col_segment'},{key:'status',label:'cl_col_status'},
{key:'last',label:'cl_col_last'},
];
let clItems=[];
let clSort={key:'',dir:1};

async function loadClients(){
const box=$('cl-tbl'); if(!box)return;
const p=new URLSearchParams();
Expand All @@ -2734,18 +2743,25 @@
if(st)p.set('status',st);
if(tag)p.set('tag',tag);
p.set('limit',lim||'100');
// Server-side sort so ordering spans the whole filtered set, not just the loaded page.
if(clSort.key){ p.set('sort',clSort.key); p.set('dir',clSort.dir>0?'asc':'desc'); }
box.innerHTML=`<div style="color:var(--text3);padding:20px;font-family:var(--mono);font-size:11px">${t('cl_working')}</div>`;
const d=await api('/clients?'+p);
const items=(d&&d.items)||[];
clItems=(d&&d.items)||[];
clRenderTable();
}

function clRenderTable(){
const box=$('cl-tbl'); if(!box)return;
const items=clItems.slice();
const arrow=k=>clSort.key===k?(clSort.dir>0?' ▲':' ▼'):'';
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 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>
${CL_COLS.map((col,i)=>`<th class="cl-sortable" style="cursor:pointer;user-select:none" onclick="clHeaderClick(event,'${col.key}')" title="${t('cl_sort_hint')}">${t(col.label)}${arrow(col.key)}<span class="col-resizer" data-c="${i}"></span></th>`).join('')}<th></th>
</tr></thead><tbody>
${items.length?items.map(c=>`<tr>
<td class="trunc" title="${esc(c.name)}"><strong>${esc(c.name)||'—'}</strong></td>
Expand All @@ -2762,6 +2778,14 @@
clInitResize();
}

// Header click → toggle sort direction, or switch column (dates default newest-first). Refetches sorted.
function clHeaderClick(e,key){
if(e&&e.target&&e.target.classList.contains('col-resizer'))return;
if(clSort.key===key) clSort.dir=-clSort.dir;
else { clSort.key=key; clSort.dir=(key==='last'?-1:1); }
loadClients();
}

// Draggable column widths for the contacts table (persisted in localStorage).
function clInitResize(){
const table=document.querySelector('#cl-tbl table.cltbl'); if(!table)return;
Expand Down
Loading