diff --git a/admin/src/pages/sms/SmsContactsPage.tsx b/admin/src/pages/sms/SmsContactsPage.tsx index 3bd1fe34..924c7ced 100644 --- a/admin/src/pages/sms/SmsContactsPage.tsx +++ b/admin/src/pages/sms/SmsContactsPage.tsx @@ -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(); const { message } = App.useApp(); + // --- Contacts (entries) state --- + const [entries, setEntries] = useState([]); + 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(); + + // --- Lists state --- const [lists, setLists] = useState([]); - 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(null); const [csvText, setCsvText] = useState(''); - const [drawerOpen, setDrawerOpen] = useState(false); - const [selectedList, setSelectedList] = useState(null); - const [entries, setEntries] = useState([]); - 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>('/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>(`/sms/contacts/${listId}/entries`, { params: { page: p, limit: 100 } }); + const params: Record = { page: p, limit: 50 }; + if (filterListId) params.listId = filterListId; + if (debouncedSearch) params.search = debouncedSearch; + const { data } = await api.get>('/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>('/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() { ); - const columns: ColumnsType = [ + // --- Main contacts table columns --- + const contactColumns: ColumnsType = [ + { title: 'Phone', dataIndex: 'phone', width: 140 }, + { title: 'Name', dataIndex: 'name', render: (v) => v || - }, + { title: 'Email', dataIndex: 'email', render: (v) => v || -, responsive: ['md'] }, + { + title: 'List', + dataIndex: ['list', 'name'], + width: 180, + render: (name: string, record) => { + if (!record.list) return -; + return ( + setFilterListId(record.list!.id)} + > + {name} + + ); + }, + }, + { + title: 'Added', + dataIndex: 'createdAt', + width: 110, + render: (d) => new Date(d).toLocaleDateString(), + responsive: ['lg'], + }, + { + title: '', + width: 40, + render: (_, record) => ( + handleDeleteEntry(record)}> +