Redesign SMS Contacts page to contacts-first view with cross-list search

Add getAllEntries API endpoint to query individual contacts across all lists
with optional list filter and case-insensitive search. Redesign the frontend
from a lists-only table to a contacts-first layout with search, list filter
dropdown, and a collapsible lists management panel.

Bunker Admin
This commit is contained in:
bunker-admin 2026-02-28 16:48:01 -07:00
parent d98488c1dc
commit d835f0837b
4 changed files with 229 additions and 92 deletions

View File

@ -1,11 +1,12 @@
import { useState, useEffect, useCallback } from 'react';
import { Table, Button, Modal, Form, Input, Space, Drawer, Upload, App, Typography, Popconfirm, Tabs, Select } from 'antd';
import { PlusOutlined, UploadOutlined, PhoneOutlined, DeleteOutlined, ImportOutlined, DatabaseOutlined, UserOutlined, EnvironmentOutlined, CalendarOutlined, MessageOutlined, ContactsOutlined } from '@ant-design/icons';
import { Table, Button, Modal, Form, Input, Space, Upload, App, Typography, Popconfirm, Tabs, Select, Collapse, Tag } from 'antd';
import { PlusOutlined, UploadOutlined, PhoneOutlined, DeleteOutlined, ImportOutlined, DatabaseOutlined, UserOutlined, EnvironmentOutlined, CalendarOutlined, MessageOutlined, ContactsOutlined, UnorderedListOutlined } from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import { api } from '@/lib/api';
import type { SmsContactList, SmsContactListEntry, SmsPaginatedResponse } from '@/types/sms';
import type { AppOutletContext } from '@/types/api';
import { useOutletContext } from 'react-router-dom';
import { useDebounce } from '@/hooks/useDebounce';
const { Text } = Typography;
const { TextArea } = Input;
@ -19,20 +20,26 @@ export default function SmsContactsPage() {
const { setPageHeader } = useOutletContext<AppOutletContext>();
const { message } = App.useApp();
// --- Contacts (entries) state ---
const [entries, setEntries] = useState<SmsContactListEntry[]>([]);
const [entriesTotal, setEntriesTotal] = useState(0);
const [entriesPage, setEntriesPage] = useState(1);
const [entriesLoading, setEntriesLoading] = useState(true);
const [searchText, setSearchText] = useState('');
const debouncedSearch = useDebounce(searchText, 300);
const [filterListId, setFilterListId] = useState<string | undefined>();
// --- Lists state ---
const [lists, setLists] = useState<SmsContactList[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(true);
const [listsTotal, setListsTotal] = useState(0);
const [listsPage, setListsPage] = useState(1);
const [listsLoading, setListsLoading] = useState(true);
// --- Modals ---
const [createOpen, setCreateOpen] = useState(false);
const [importOpen, setImportOpen] = useState(false);
const [importListId, setImportListId] = useState<string | null>(null);
const [csvText, setCsvText] = useState('');
const [drawerOpen, setDrawerOpen] = useState(false);
const [selectedList, setSelectedList] = useState<SmsContactList | null>(null);
const [entries, setEntries] = useState<SmsContactListEntry[]>([]);
const [entriesTotal, setEntriesTotal] = useState(0);
const [entriesPage, setEntriesPage] = useState(1);
const [entriesLoading, setEntriesLoading] = useState(false);
const [createForm] = Form.useForm();
// Import modal state
@ -55,34 +62,46 @@ export default function SmsContactsPage() {
const [smsCampaigns, setSmsCampaigns] = useState<{ id: string; name: string }[]>([]);
useEffect(() => {
setPageHeader({ title: 'SMS Contacts', subtitle: 'Manage contact lists for SMS campaigns' });
setPageHeader({ title: 'SMS Contacts', subtitle: 'Manage contacts across all lists' });
}, [setPageHeader]);
const fetchLists = useCallback(async () => {
setLoading(true);
try {
const { data } = await api.get<SmsPaginatedResponse<SmsContactList>>('/sms/contacts', { params: { page, limit: 50 } });
setLists(data.items);
setTotal(data.total);
} finally {
setLoading(false);
}
}, [page]);
useEffect(() => { fetchLists(); }, [fetchLists]);
const fetchEntries = useCallback(async (listId: string, p = 1) => {
// --- Fetch all entries (contacts-first view) ---
const fetchEntries = useCallback(async (p = 1) => {
setEntriesLoading(true);
try {
const { data } = await api.get<SmsPaginatedResponse<SmsContactListEntry>>(`/sms/contacts/${listId}/entries`, { params: { page: p, limit: 100 } });
const params: Record<string, string | number> = { page: p, limit: 50 };
if (filterListId) params.listId = filterListId;
if (debouncedSearch) params.search = debouncedSearch;
const { data } = await api.get<SmsPaginatedResponse<SmsContactListEntry>>('/sms/contacts/all-entries', { params });
setEntries(data.items);
setEntriesTotal(data.total);
setEntriesPage(p);
} finally {
setEntriesLoading(false);
}
}, [filterListId, debouncedSearch]);
// --- Fetch lists (for dropdown + lists panel) ---
const fetchLists = useCallback(async (p = 1) => {
setListsLoading(true);
try {
const { data } = await api.get<SmsPaginatedResponse<SmsContactList>>('/sms/contacts', { params: { page: p, limit: 50 } });
setLists(data.items);
setListsTotal(data.total);
setListsPage(p);
} finally {
setListsLoading(false);
}
}, []);
useEffect(() => { fetchLists(); }, [fetchLists]);
// Re-fetch entries when filters or page change
useEffect(() => {
setEntriesPage(1);
fetchEntries(1);
}, [fetchEntries]);
// Load shifts and SMS campaigns for filter dropdowns when import modal opens
const loadFilterOptions = useCallback(async () => {
try {
@ -99,11 +118,13 @@ export default function SmsContactsPage() {
const handleCreate = async (values: { name: string }) => {
try {
await api.post('/sms/contacts', values);
const { data } = await api.post('/sms/contacts', values);
message.success('Contact list created');
setCreateOpen(false);
createForm.resetFields();
fetchLists();
// Auto-select the new list in the filter
setFilterListId(data.id);
} catch {
message.error('Failed to create list');
}
@ -113,7 +134,10 @@ export default function SmsContactsPage() {
try {
await api.delete(`/sms/contacts/${id}`);
message.success('List archived');
// If we're filtering by this list, clear the filter
if (filterListId === id) setFilterListId(undefined);
fetchLists();
fetchEntries(1);
} catch {
message.error('Failed to archive list');
}
@ -128,7 +152,7 @@ export default function SmsContactsPage() {
setImportOpen(false);
resetImportState();
fetchLists();
if (selectedList?.id === importListId) fetchEntries(importListId);
fetchEntries(entriesPage);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'Import failed';
message.error(msg);
@ -146,7 +170,7 @@ export default function SmsContactsPage() {
setImportOpen(false);
resetImportState();
fetchLists();
if (selectedList?.id === importListId) fetchEntries(importListId);
fetchEntries(entriesPage);
} catch {
message.error('Failed to import from phone');
} finally {
@ -154,26 +178,28 @@ export default function SmsContactsPage() {
}
};
const handleDeleteEntry = async (entryId: string) => {
if (!selectedList) return;
const handleDeleteEntry = async (entry: SmsContactListEntry) => {
try {
await api.delete(`/sms/contacts/${selectedList.id}/entries/${entryId}`);
message.success('Entry removed');
fetchEntries(selectedList.id, entriesPage);
await api.delete(`/sms/contacts/${entry.listId}/entries/${entry.id}`);
message.success('Contact removed');
fetchEntries(entriesPage);
fetchLists();
} catch {
message.error('Failed to remove entry');
message.error('Failed to remove contact');
}
};
const openDrawer = (list: SmsContactList) => {
setSelectedList(list);
setDrawerOpen(true);
fetchEntries(list.id);
};
const openImportModal = (listId: string) => {
setImportListId(listId);
const openImportModal = (listId?: string) => {
if (listId) {
setImportListId(listId);
} else if (filterListId) {
setImportListId(filterListId);
} else if (lists.length === 1) {
setImportListId(lists[0].id);
} else {
message.info('Select a list first, or use the filter dropdown to choose one');
return;
}
setImportOpen(true);
loadFilterOptions();
};
@ -254,7 +280,7 @@ export default function SmsContactsPage() {
setImportOpen(false);
resetImportState();
fetchLists();
if (selectedList?.id === importListId) fetchEntries(importListId);
fetchEntries(entriesPage);
} catch {
message.error('Import failed');
} finally {
@ -298,23 +324,68 @@ export default function SmsContactsPage() {
</div>
);
const columns: ColumnsType<SmsContactList> = [
// --- Main contacts table columns ---
const contactColumns: ColumnsType<SmsContactListEntry> = [
{ title: 'Phone', dataIndex: 'phone', width: 140 },
{ title: 'Name', dataIndex: 'name', render: (v) => v || <Text type="secondary">-</Text> },
{ title: 'Email', dataIndex: 'email', render: (v) => v || <Text type="secondary">-</Text>, responsive: ['md'] },
{
title: 'List',
dataIndex: ['list', 'name'],
width: 180,
render: (name: string, record) => {
if (!record.list) return <Text type="secondary">-</Text>;
return (
<Tag
color="blue"
style={{ cursor: 'pointer' }}
onClick={() => setFilterListId(record.list!.id)}
>
{name}
</Tag>
);
},
},
{
title: 'Added',
dataIndex: 'createdAt',
width: 110,
render: (d) => new Date(d).toLocaleDateString(),
responsive: ['lg'],
},
{
title: '',
width: 40,
render: (_, record) => (
<Popconfirm title="Remove this contact?" onConfirm={() => handleDeleteEntry(record)}>
<Button size="small" type="text" danger icon={<DeleteOutlined />} />
</Popconfirm>
),
},
];
// --- Lists panel columns ---
const listColumns: ColumnsType<SmsContactList> = [
{
title: 'Name',
dataIndex: 'name',
render: (name, record) => <a onClick={() => openDrawer(record)}>{name}</a>,
render: (name, record) => (
<a onClick={() => setFilterListId(record.id)}>{name}</a>
),
},
{ title: 'Contacts', dataIndex: 'totalContacts', width: 100 },
{ title: 'Contacts', dataIndex: 'totalContacts', width: 90 },
{
title: 'Source',
dataIndex: 'originalFilename',
render: (f) => f || <Text type="secondary">Manual</Text>,
responsive: ['md'],
},
{
title: 'Created',
dataIndex: 'createdAt',
render: (d) => new Date(d).toLocaleDateString(),
width: 120,
width: 110,
responsive: ['lg'],
},
{
title: 'Actions',
@ -330,21 +401,6 @@ export default function SmsContactsPage() {
},
];
const entryColumns: ColumnsType<SmsContactListEntry> = [
{ title: 'Phone', dataIndex: 'phone', width: 140 },
{ title: 'Name', dataIndex: 'name', render: (v) => v || <Text type="secondary">-</Text> },
{ title: 'Email', dataIndex: 'email', render: (v) => v || <Text type="secondary">-</Text> },
{
title: '',
width: 40,
render: (_, record) => (
<Popconfirm title="Remove?" onConfirm={() => handleDeleteEntry(record.id)}>
<Button size="small" type="text" danger icon={<DeleteOutlined />} />
</Popconfirm>
),
},
];
const supportLevelOptions = [
{ value: 'LEVEL_1', label: 'Level 1 (Strong Support)' },
{ value: 'LEVEL_2', label: 'Level 2 (Leaning Support)' },
@ -367,21 +423,75 @@ export default function SmsContactsPage() {
{ value: 'MAP_ADMIN', label: 'Map Admin' },
];
const importListName = importListId ? lists.find(l => l.id === importListId)?.name : undefined;
return (
<>
<Space style={{ marginBottom: 16 }}>
{/* Top toolbar */}
<Space wrap style={{ marginBottom: 16, width: '100%' }}>
<Input.Search
placeholder="Search phone, name, or email..."
allowClear
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
style={{ width: 260 }}
/>
<Select
placeholder="All Lists"
allowClear
showSearch
optionFilterProp="label"
style={{ width: 200 }}
value={filterListId}
onChange={(v) => setFilterListId(v)}
options={lists.map(l => ({ value: l.id, label: `${l.name} (${l.totalContacts})` }))}
/>
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}>New List</Button>
<Button icon={<ImportOutlined />} onClick={() => openImportModal()}>Import</Button>
</Space>
{/* Main contacts table */}
<Table
rowKey="id"
columns={columns}
dataSource={lists}
loading={loading}
pagination={{ current: page, total, pageSize: 50, onChange: setPage }}
columns={contactColumns}
dataSource={entries}
loading={entriesLoading}
pagination={{
current: entriesPage,
total: entriesTotal,
pageSize: 50,
onChange: (p) => fetchEntries(p),
showTotal: (t) => `${t} contacts`,
showSizeChanger: false,
}}
size="middle"
/>
{/* Lists management panel */}
<Collapse
style={{ marginTop: 16 }}
items={[{
key: 'lists',
label: <span><UnorderedListOutlined /> Manage Lists ({listsTotal})</span>,
children: (
<Table
rowKey="id"
columns={listColumns}
dataSource={lists}
loading={listsLoading}
pagination={{
current: listsPage,
total: listsTotal,
pageSize: 50,
onChange: (p) => fetchLists(p),
showSizeChanger: false,
}}
size="small"
/>
),
}]}
/>
{/* Create List Modal */}
<Modal
title="New Contact List"
@ -398,7 +508,7 @@ export default function SmsContactsPage() {
{/* Unified Import Modal */}
<Modal
title="Import Contacts"
title={`Import Contacts${importListName ? `${importListName}` : ''}`}
open={importOpen}
onCancel={() => { setImportOpen(false); resetImportState(); }}
footer={null}
@ -600,28 +710,6 @@ export default function SmsContactsPage() {
]}
/>
</Modal>
{/* Entries Drawer */}
<Drawer
title={selectedList ? `${selectedList.name} (${selectedList.totalContacts} contacts)` : 'Entries'}
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
width={600}
>
<Table
rowKey="id"
columns={entryColumns}
dataSource={entries}
loading={entriesLoading}
pagination={{
current: entriesPage,
total: entriesTotal,
pageSize: 100,
onChange: (p) => selectedList && fetchEntries(selectedList.id, p),
}}
size="small"
/>
</Drawer>
</>
);
}

View File

@ -21,6 +21,7 @@ export interface SmsContactListEntry {
email: string | null;
customFields: Record<string, string> | null;
createdAt: string;
list?: { id: string; name: string };
}
// --- Campaigns ---
@ -106,6 +107,16 @@ export interface SmsConversation {
updatedAt: string;
}
// --- Contact Search ---
export interface SmsContactSearchResult {
phone: string;
name: string | null;
source: 'sms_contact' | 'crm_contact' | 'conversation';
sourceId: string;
contactId?: string;
}
// --- Templates ---
export interface SmsMessageTemplate {

View File

@ -30,6 +30,18 @@ router.post('/', validate(createContactListSchema), async (req, res, next) => {
} catch (err) { next(err); }
});
// GET /api/sms/contacts/all-entries — list entries across all lists
router.get('/all-entries', async (req, res, next) => {
try {
const page = Math.max(1, Number(req.query.page) || 1);
const limit = Math.min(100, Math.max(1, Number(req.query.limit) || 50));
const listId = req.query.listId as string | undefined;
const search = req.query.search as string | undefined;
const result = await smsContactsService.getAllEntries(page, limit, listId, search);
res.json(result);
} catch (err) { next(err); }
});
// --- Database Import Previews (must be BEFORE /:id routes) ---
// GET /api/sms/contacts/preview-users — preview users with phone numbers

View File

@ -116,6 +116,32 @@ export const smsContactsService = {
});
},
async getAllEntries(page = 1, limit = 50, listId?: string, search?: string) {
const skip = (page - 1) * limit;
const where: Prisma.SmsContactListEntryWhereInput = {
list: { status: 'ACTIVE' },
};
if (listId) where.listId = listId;
if (search) {
where.OR = [
{ phone: { contains: search } },
{ name: { contains: search, mode: 'insensitive' } },
{ email: { contains: search, mode: 'insensitive' } },
];
}
const [items, total] = await Promise.all([
prisma.smsContactListEntry.findMany({
where,
orderBy: { createdAt: 'desc' },
skip,
take: limit,
include: { list: { select: { id: true, name: true } } },
}),
prisma.smsContactListEntry.count({ where }),
]);
return { items, total, page, limit };
},
async getEntries(listId: string, page = 1, limit = 100) {
const skip = (page - 1) * limit;
const [items, total] = await Promise.all([