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 { useState, useEffect, useCallback } from 'react';
|
||||||
import { Table, Button, Modal, Form, Input, Space, Drawer, Upload, App, Typography, Popconfirm, Tabs, Select } from 'antd';
|
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 } from '@ant-design/icons';
|
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 type { ColumnsType } from 'antd/es/table';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import type { SmsContactList, SmsContactListEntry, SmsPaginatedResponse } from '@/types/sms';
|
import type { SmsContactList, SmsContactListEntry, SmsPaginatedResponse } from '@/types/sms';
|
||||||
import type { AppOutletContext } from '@/types/api';
|
import type { AppOutletContext } from '@/types/api';
|
||||||
import { useOutletContext } from 'react-router-dom';
|
import { useOutletContext } from 'react-router-dom';
|
||||||
|
import { useDebounce } from '@/hooks/useDebounce';
|
||||||
|
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
const { TextArea } = Input;
|
const { TextArea } = Input;
|
||||||
@ -19,20 +20,26 @@ export default function SmsContactsPage() {
|
|||||||
const { setPageHeader } = useOutletContext<AppOutletContext>();
|
const { setPageHeader } = useOutletContext<AppOutletContext>();
|
||||||
const { message } = App.useApp();
|
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 [lists, setLists] = useState<SmsContactList[]>([]);
|
||||||
const [total, setTotal] = useState(0);
|
const [listsTotal, setListsTotal] = useState(0);
|
||||||
const [page, setPage] = useState(1);
|
const [listsPage, setListsPage] = useState(1);
|
||||||
const [loading, setLoading] = useState(true);
|
const [listsLoading, setListsLoading] = useState(true);
|
||||||
|
|
||||||
|
// --- Modals ---
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
const [importOpen, setImportOpen] = useState(false);
|
const [importOpen, setImportOpen] = useState(false);
|
||||||
const [importListId, setImportListId] = useState<string | null>(null);
|
const [importListId, setImportListId] = useState<string | null>(null);
|
||||||
const [csvText, setCsvText] = useState('');
|
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();
|
const [createForm] = Form.useForm();
|
||||||
|
|
||||||
// Import modal state
|
// Import modal state
|
||||||
@ -55,34 +62,46 @@ export default function SmsContactsPage() {
|
|||||||
const [smsCampaigns, setSmsCampaigns] = useState<{ id: string; name: string }[]>([]);
|
const [smsCampaigns, setSmsCampaigns] = useState<{ id: string; name: string }[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setPageHeader({ title: 'SMS Contacts', subtitle: 'Manage contact lists for SMS campaigns' });
|
setPageHeader({ title: 'SMS Contacts', subtitle: 'Manage contacts across all lists' });
|
||||||
}, [setPageHeader]);
|
}, [setPageHeader]);
|
||||||
|
|
||||||
const fetchLists = useCallback(async () => {
|
// --- Fetch all entries (contacts-first view) ---
|
||||||
setLoading(true);
|
const fetchEntries = useCallback(async (p = 1) => {
|
||||||
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) => {
|
|
||||||
setEntriesLoading(true);
|
setEntriesLoading(true);
|
||||||
try {
|
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);
|
setEntries(data.items);
|
||||||
setEntriesTotal(data.total);
|
setEntriesTotal(data.total);
|
||||||
setEntriesPage(p);
|
setEntriesPage(p);
|
||||||
} finally {
|
} finally {
|
||||||
setEntriesLoading(false);
|
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
|
// Load shifts and SMS campaigns for filter dropdowns when import modal opens
|
||||||
const loadFilterOptions = useCallback(async () => {
|
const loadFilterOptions = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@ -99,11 +118,13 @@ export default function SmsContactsPage() {
|
|||||||
|
|
||||||
const handleCreate = async (values: { name: string }) => {
|
const handleCreate = async (values: { name: string }) => {
|
||||||
try {
|
try {
|
||||||
await api.post('/sms/contacts', values);
|
const { data } = await api.post('/sms/contacts', values);
|
||||||
message.success('Contact list created');
|
message.success('Contact list created');
|
||||||
setCreateOpen(false);
|
setCreateOpen(false);
|
||||||
createForm.resetFields();
|
createForm.resetFields();
|
||||||
fetchLists();
|
fetchLists();
|
||||||
|
// Auto-select the new list in the filter
|
||||||
|
setFilterListId(data.id);
|
||||||
} catch {
|
} catch {
|
||||||
message.error('Failed to create list');
|
message.error('Failed to create list');
|
||||||
}
|
}
|
||||||
@ -113,7 +134,10 @@ export default function SmsContactsPage() {
|
|||||||
try {
|
try {
|
||||||
await api.delete(`/sms/contacts/${id}`);
|
await api.delete(`/sms/contacts/${id}`);
|
||||||
message.success('List archived');
|
message.success('List archived');
|
||||||
|
// If we're filtering by this list, clear the filter
|
||||||
|
if (filterListId === id) setFilterListId(undefined);
|
||||||
fetchLists();
|
fetchLists();
|
||||||
|
fetchEntries(1);
|
||||||
} catch {
|
} catch {
|
||||||
message.error('Failed to archive list');
|
message.error('Failed to archive list');
|
||||||
}
|
}
|
||||||
@ -128,7 +152,7 @@ export default function SmsContactsPage() {
|
|||||||
setImportOpen(false);
|
setImportOpen(false);
|
||||||
resetImportState();
|
resetImportState();
|
||||||
fetchLists();
|
fetchLists();
|
||||||
if (selectedList?.id === importListId) fetchEntries(importListId);
|
fetchEntries(entriesPage);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const msg = err instanceof Error ? err.message : 'Import failed';
|
const msg = err instanceof Error ? err.message : 'Import failed';
|
||||||
message.error(msg);
|
message.error(msg);
|
||||||
@ -146,7 +170,7 @@ export default function SmsContactsPage() {
|
|||||||
setImportOpen(false);
|
setImportOpen(false);
|
||||||
resetImportState();
|
resetImportState();
|
||||||
fetchLists();
|
fetchLists();
|
||||||
if (selectedList?.id === importListId) fetchEntries(importListId);
|
fetchEntries(entriesPage);
|
||||||
} catch {
|
} catch {
|
||||||
message.error('Failed to import from phone');
|
message.error('Failed to import from phone');
|
||||||
} finally {
|
} finally {
|
||||||
@ -154,26 +178,28 @@ export default function SmsContactsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteEntry = async (entryId: string) => {
|
const handleDeleteEntry = async (entry: SmsContactListEntry) => {
|
||||||
if (!selectedList) return;
|
|
||||||
try {
|
try {
|
||||||
await api.delete(`/sms/contacts/${selectedList.id}/entries/${entryId}`);
|
await api.delete(`/sms/contacts/${entry.listId}/entries/${entry.id}`);
|
||||||
message.success('Entry removed');
|
message.success('Contact removed');
|
||||||
fetchEntries(selectedList.id, entriesPage);
|
fetchEntries(entriesPage);
|
||||||
fetchLists();
|
fetchLists();
|
||||||
} catch {
|
} catch {
|
||||||
message.error('Failed to remove entry');
|
message.error('Failed to remove contact');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const openDrawer = (list: SmsContactList) => {
|
const openImportModal = (listId?: string) => {
|
||||||
setSelectedList(list);
|
if (listId) {
|
||||||
setDrawerOpen(true);
|
setImportListId(listId);
|
||||||
fetchEntries(list.id);
|
} else if (filterListId) {
|
||||||
};
|
setImportListId(filterListId);
|
||||||
|
} else if (lists.length === 1) {
|
||||||
const openImportModal = (listId: string) => {
|
setImportListId(lists[0].id);
|
||||||
setImportListId(listId);
|
} else {
|
||||||
|
message.info('Select a list first, or use the filter dropdown to choose one');
|
||||||
|
return;
|
||||||
|
}
|
||||||
setImportOpen(true);
|
setImportOpen(true);
|
||||||
loadFilterOptions();
|
loadFilterOptions();
|
||||||
};
|
};
|
||||||
@ -254,7 +280,7 @@ export default function SmsContactsPage() {
|
|||||||
setImportOpen(false);
|
setImportOpen(false);
|
||||||
resetImportState();
|
resetImportState();
|
||||||
fetchLists();
|
fetchLists();
|
||||||
if (selectedList?.id === importListId) fetchEntries(importListId);
|
fetchEntries(entriesPage);
|
||||||
} catch {
|
} catch {
|
||||||
message.error('Import failed');
|
message.error('Import failed');
|
||||||
} finally {
|
} finally {
|
||||||
@ -298,23 +324,68 @@ export default function SmsContactsPage() {
|
|||||||
</div>
|
</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',
|
title: 'Name',
|
||||||
dataIndex: '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',
|
title: 'Source',
|
||||||
dataIndex: 'originalFilename',
|
dataIndex: 'originalFilename',
|
||||||
render: (f) => f || <Text type="secondary">Manual</Text>,
|
render: (f) => f || <Text type="secondary">Manual</Text>,
|
||||||
|
responsive: ['md'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Created',
|
title: 'Created',
|
||||||
dataIndex: 'createdAt',
|
dataIndex: 'createdAt',
|
||||||
render: (d) => new Date(d).toLocaleDateString(),
|
render: (d) => new Date(d).toLocaleDateString(),
|
||||||
width: 120,
|
width: 110,
|
||||||
|
responsive: ['lg'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Actions',
|
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 = [
|
const supportLevelOptions = [
|
||||||
{ value: 'LEVEL_1', label: 'Level 1 (Strong Support)' },
|
{ value: 'LEVEL_1', label: 'Level 1 (Strong Support)' },
|
||||||
{ value: 'LEVEL_2', label: 'Level 2 (Leaning Support)' },
|
{ value: 'LEVEL_2', label: 'Level 2 (Leaning Support)' },
|
||||||
@ -367,21 +423,75 @@ export default function SmsContactsPage() {
|
|||||||
{ value: 'MAP_ADMIN', label: 'Map Admin' },
|
{ value: 'MAP_ADMIN', label: 'Map Admin' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const importListName = importListId ? lists.find(l => l.id === importListId)?.name : undefined;
|
||||||
|
|
||||||
return (
|
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 type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}>New List</Button>
|
||||||
|
<Button icon={<ImportOutlined />} onClick={() => openImportModal()}>Import</Button>
|
||||||
</Space>
|
</Space>
|
||||||
|
|
||||||
|
{/* Main contacts table */}
|
||||||
<Table
|
<Table
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
columns={columns}
|
columns={contactColumns}
|
||||||
dataSource={lists}
|
dataSource={entries}
|
||||||
loading={loading}
|
loading={entriesLoading}
|
||||||
pagination={{ current: page, total, pageSize: 50, onChange: setPage }}
|
pagination={{
|
||||||
|
current: entriesPage,
|
||||||
|
total: entriesTotal,
|
||||||
|
pageSize: 50,
|
||||||
|
onChange: (p) => fetchEntries(p),
|
||||||
|
showTotal: (t) => `${t} contacts`,
|
||||||
|
showSizeChanger: false,
|
||||||
|
}}
|
||||||
size="middle"
|
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 */}
|
{/* Create List Modal */}
|
||||||
<Modal
|
<Modal
|
||||||
title="New Contact List"
|
title="New Contact List"
|
||||||
@ -398,7 +508,7 @@ export default function SmsContactsPage() {
|
|||||||
|
|
||||||
{/* Unified Import Modal */}
|
{/* Unified Import Modal */}
|
||||||
<Modal
|
<Modal
|
||||||
title="Import Contacts"
|
title={`Import Contacts${importListName ? ` → ${importListName}` : ''}`}
|
||||||
open={importOpen}
|
open={importOpen}
|
||||||
onCancel={() => { setImportOpen(false); resetImportState(); }}
|
onCancel={() => { setImportOpen(false); resetImportState(); }}
|
||||||
footer={null}
|
footer={null}
|
||||||
@ -600,28 +710,6 @@ export default function SmsContactsPage() {
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</Modal>
|
</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;
|
email: string | null;
|
||||||
customFields: Record<string, string> | null;
|
customFields: Record<string, string> | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
list?: { id: string; name: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Campaigns ---
|
// --- Campaigns ---
|
||||||
@ -106,6 +107,16 @@ export interface SmsConversation {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Contact Search ---
|
||||||
|
|
||||||
|
export interface SmsContactSearchResult {
|
||||||
|
phone: string;
|
||||||
|
name: string | null;
|
||||||
|
source: 'sms_contact' | 'crm_contact' | 'conversation';
|
||||||
|
sourceId: string;
|
||||||
|
contactId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
// --- Templates ---
|
// --- Templates ---
|
||||||
|
|
||||||
export interface SmsMessageTemplate {
|
export interface SmsMessageTemplate {
|
||||||
|
|||||||
@ -30,6 +30,18 @@ router.post('/', validate(createContactListSchema), async (req, res, next) => {
|
|||||||
} catch (err) { next(err); }
|
} 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) ---
|
// --- Database Import Previews (must be BEFORE /:id routes) ---
|
||||||
|
|
||||||
// GET /api/sms/contacts/preview-users — preview users with phone numbers
|
// 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) {
|
async getEntries(listId: string, page = 1, limit = 100) {
|
||||||
const skip = (page - 1) * limit;
|
const skip = (page - 1) * limit;
|
||||||
const [items, total] = await Promise.all([
|
const [items, total] = await Promise.all([
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user