diff --git a/src/core/clients.ts b/src/core/clients.ts index 4cfb77b..49076d2 100644 --- a/src/core/clients.ts +++ b/src/core/clients.ts @@ -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 = { + name: "name", + email: "email", + org: "org", + region: "region", + segment: "segment", + status: "status", + last: "last_contacted_at", +}; + export async function listClients(opts: ListClientsOptions): Promise { + // 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); diff --git a/src/web/api.ts b/src/web/api.ts index 4da37f7..bb951b9 100644 --- a/src/web/api.ts +++ b/src/web/api.ts @@ -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 }); diff --git a/src/web/public/index.html b/src/web/public/index.html index 6565501..e9a18b4 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -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', @@ -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:'Сегмент', @@ -2726,6 +2726,15 @@ `; } +// 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(); @@ -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=`
${t('cl_working')}
`; 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=`
${items.length} ${t('cl_shown')}
- - - + ${CL_COLS.map((col,i)=>``).join('')} ${items.length?items.map(c=>` @@ -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;
${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')}${t(col.label)}${arrow(col.key)}
${esc(c.name)||'—'}