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:
parent
d98488c1dc
commit
d835f0837b
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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([
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user