Seth commited on
Commit
05ce0ac
·
1 Parent(s): b645a7a
Files changed (2) hide show
  1. backend/app/main.py +18 -0
  2. frontend/src/pages/Contacts.jsx +114 -45
backend/app/main.py CHANGED
@@ -201,6 +201,8 @@ async def list_contacts(
201
  op: str = Query("contains", description="Filter op: contains|equals|from|to|between"),
202
  from_value: str = Query("", description="From value for range filters"),
203
  to_value: str = Query("", description="To value for range filters"),
 
 
204
  limit: int = Query(200, ge=1, le=1000),
205
  offset: int = Query(0, ge=0),
206
  db: Session = Depends(get_db)
@@ -261,6 +263,22 @@ async def list_contacts(
261
 
262
  contacts_all = [c for c in contacts_all if match_dynamic(c)]
263
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
264
  total = len(contacts_all)
265
  contacts = contacts_all[offset:offset + limit]
266
  return {
 
201
  op: str = Query("contains", description="Filter op: contains|equals|from|to|between"),
202
  from_value: str = Query("", description="From value for range filters"),
203
  to_value: str = Query("", description="To value for range filters"),
204
+ sort_by: str = Query("created_at", description="Sort field"),
205
+ sort_dir: str = Query("desc", description="Sort direction: asc|desc"),
206
  limit: int = Query(200, ge=1, le=1000),
207
  offset: int = Query(0, ge=0),
208
  db: Session = Depends(get_db)
 
263
 
264
  contacts_all = [c for c in contacts_all if match_dynamic(c)]
265
 
266
+ # Sort by mapped or raw Apollo fields
267
+ reverse = (sort_dir or "desc").lower() != "asc"
268
+ sort_field = sort_by or "created_at"
269
+
270
+ def sort_key(c: Contact):
271
+ val = _contact_value(c, sort_field)
272
+ num = _to_float(val)
273
+ if num is not None:
274
+ return (0, num)
275
+ dt = _to_datetime(val)
276
+ if dt is not None:
277
+ return (1, dt.timestamp())
278
+ return (2, _safe_str(val).lower())
279
+
280
+ contacts_all = sorted(contacts_all, key=sort_key, reverse=reverse)
281
+
282
  total = len(contacts_all)
283
  contacts = contacts_all[offset:offset + limit]
284
  return {
frontend/src/pages/Contacts.jsx CHANGED
@@ -19,6 +19,11 @@ export default function Contacts() {
19
  const [filterValue, setFilterValue] = useState('');
20
  const [filterFrom, setFilterFrom] = useState('');
21
  const [filterTo, setFilterTo] = useState('');
 
 
 
 
 
22
 
23
  useEffect(() => {
24
  fetchFields();
@@ -28,7 +33,11 @@ export default function Contacts() {
28
  useEffect(() => {
29
  const timer = setTimeout(() => fetchContacts(), 250);
30
  return () => clearTimeout(timer);
31
- }, [searchQuery, filterField, filterOp, filterValue, filterFrom, filterTo]);
 
 
 
 
32
 
33
  const fetchFields = async () => {
34
  try {
@@ -46,19 +55,22 @@ export default function Contacts() {
46
  setLoading(true);
47
  try {
48
  const params = new URLSearchParams();
49
- params.set('limit', '1000');
 
 
 
 
 
50
  if (searchQuery.trim()) params.set('search', searchQuery.trim());
51
  if (filterField !== 'none') {
52
  params.set('field', filterField);
53
- params.set('op', filterOp);
54
- if (filterOp === 'between') {
 
55
  if (filterFrom.trim()) params.set('from_value', filterFrom.trim());
56
  if (filterTo.trim()) params.set('to_value', filterTo.trim());
57
- } else if (filterOp === 'from') {
58
- if (filterFrom.trim()) params.set('from_value', filterFrom.trim());
59
- } else if (filterOp === 'to') {
60
- if (filterTo.trim()) params.set('to_value', filterTo.trim());
61
- } else if (filterValue.trim()) {
62
  params.set('value', filterValue.trim());
63
  }
64
  }
@@ -66,6 +78,7 @@ export default function Contacts() {
66
  if (res.ok) {
67
  const data = await res.json();
68
  setContacts(data.contacts || []);
 
69
  }
70
  } catch (e) {
71
  console.error('Failed to fetch contacts:', e);
@@ -99,6 +112,8 @@ export default function Contacts() {
99
  }
100
  };
101
 
 
 
102
  return (
103
  <AppShell
104
  title="Contacts"
@@ -152,21 +167,7 @@ export default function Contacts() {
152
  onChange={(e) => setFilterValue(e.target.value)}
153
  />
154
  )}
155
- {filterOp === 'from' && (
156
- <Input
157
- placeholder="From value"
158
- value={filterFrom}
159
- onChange={(e) => setFilterFrom(e.target.value)}
160
- />
161
- )}
162
- {filterOp === 'to' && (
163
- <Input
164
- placeholder="To value"
165
- value={filterTo}
166
- onChange={(e) => setFilterTo(e.target.value)}
167
- />
168
- )}
169
- {filterOp === 'between' && (
170
  <div className="grid grid-cols-2 gap-2">
171
  <Input
172
  placeholder="From"
@@ -180,10 +181,52 @@ export default function Contacts() {
180
  />
181
  </div>
182
  )}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
183
  </>
184
  )}
185
  </div>
186
- <div className="max-h-[70vh] overflow-y-auto space-y-2 pr-1">
187
  {loading ? (
188
  <p className="text-sm text-slate-500 p-3">Loading contacts...</p>
189
  ) : contacts.length === 0 ? (
@@ -192,28 +235,54 @@ export default function Contacts() {
192
  <p>No contacts found</p>
193
  </div>
194
  ) : (
195
- contacts.map((contact) => {
196
- const active = selectedContact?.id === contact.id;
197
- return (
198
- <button
199
- key={contact.id}
200
- onClick={() => openContact(contact)}
201
- className={`w-full text-left rounded-xl border p-3 transition ${
202
- active
203
- ? 'border-violet-300 bg-violet-50'
204
- : 'border-slate-200 bg-white hover:bg-slate-50'
205
- }`}
206
- >
207
- <div className="font-medium text-slate-800">
208
- {contact.first_name} {contact.last_name}
209
- </div>
210
- <div className="text-xs text-slate-500 truncate">{contact.email || 'No email'}</div>
211
- <div className="text-xs text-slate-400 truncate">{contact.company || 'No company'}</div>
212
- </button>
213
- );
214
- })
 
 
 
 
 
 
 
215
  )}
216
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
217
  </section>
218
 
219
  <section className="lg:col-span-3 rounded-2xl border border-slate-200 bg-white p-6">
 
19
  const [filterValue, setFilterValue] = useState('');
20
  const [filterFrom, setFilterFrom] = useState('');
21
  const [filterTo, setFilterTo] = useState('');
22
+ const [sortBy, setSortBy] = useState('created_at');
23
+ const [sortDir, setSortDir] = useState('desc');
24
+ const [page, setPage] = useState(1);
25
+ const [pageSize, setPageSize] = useState('50');
26
+ const [total, setTotal] = useState(0);
27
 
28
  useEffect(() => {
29
  fetchFields();
 
33
  useEffect(() => {
34
  const timer = setTimeout(() => fetchContacts(), 250);
35
  return () => clearTimeout(timer);
36
+ }, [searchQuery, filterField, filterOp, filterValue, filterFrom, filterTo, sortBy, sortDir, page, pageSize]);
37
+
38
+ useEffect(() => {
39
+ setPage(1);
40
+ }, [searchQuery, filterField, filterOp, filterValue, filterFrom, filterTo, sortBy, sortDir, pageSize]);
41
 
42
  const fetchFields = async () => {
43
  try {
 
55
  setLoading(true);
56
  try {
57
  const params = new URLSearchParams();
58
+ const pageLimit = Number(pageSize || 50);
59
+ const pageOffset = (page - 1) * pageLimit;
60
+ params.set('limit', String(pageLimit));
61
+ params.set('offset', String(pageOffset));
62
+ params.set('sort_by', sortBy);
63
+ params.set('sort_dir', sortDir);
64
  if (searchQuery.trim()) params.set('search', searchQuery.trim());
65
  if (filterField !== 'none') {
66
  params.set('field', filterField);
67
+ // If either from/to is provided, use range behavior
68
+ if (filterFrom.trim() || filterTo.trim()) {
69
+ params.set('op', 'between');
70
  if (filterFrom.trim()) params.set('from_value', filterFrom.trim());
71
  if (filterTo.trim()) params.set('to_value', filterTo.trim());
72
+ } else {
73
+ params.set('op', filterOp);
 
 
 
74
  params.set('value', filterValue.trim());
75
  }
76
  }
 
78
  if (res.ok) {
79
  const data = await res.json();
80
  setContacts(data.contacts || []);
81
+ setTotal(data.total || 0);
82
  }
83
  } catch (e) {
84
  console.error('Failed to fetch contacts:', e);
 
112
  }
113
  };
114
 
115
+ const totalPages = Math.max(1, Math.ceil(total / Number(pageSize || 50)));
116
+
117
  return (
118
  <AppShell
119
  title="Contacts"
 
167
  onChange={(e) => setFilterValue(e.target.value)}
168
  />
169
  )}
170
+ {(filterOp === 'from' || filterOp === 'to' || filterOp === 'between') && (
 
 
 
 
 
 
 
 
 
 
 
 
 
 
171
  <div className="grid grid-cols-2 gap-2">
172
  <Input
173
  placeholder="From"
 
181
  />
182
  </div>
183
  )}
184
+ <div className="grid grid-cols-2 gap-2">
185
+ <Select value={sortBy} onValueChange={setSortBy}>
186
+ <SelectTrigger>
187
+ <SelectValue placeholder="Sort by" />
188
+ </SelectTrigger>
189
+ <SelectContent>
190
+ <SelectItem value="created_at">Created At</SelectItem>
191
+ <SelectItem value="first_name">First Name</SelectItem>
192
+ <SelectItem value="last_name">Last Name</SelectItem>
193
+ <SelectItem value="email">Email</SelectItem>
194
+ <SelectItem value="company">Company</SelectItem>
195
+ <SelectItem value="title">Title</SelectItem>
196
+ {fields.map((field) => (
197
+ <SelectItem key={`sort-${field}`} value={field}>{field}</SelectItem>
198
+ ))}
199
+ </SelectContent>
200
+ </Select>
201
+ <Select value={sortDir} onValueChange={setSortDir}>
202
+ <SelectTrigger>
203
+ <SelectValue />
204
+ </SelectTrigger>
205
+ <SelectContent>
206
+ <SelectItem value="asc">Ascending</SelectItem>
207
+ <SelectItem value="desc">Descending</SelectItem>
208
+ </SelectContent>
209
+ </Select>
210
+ </div>
211
+ <div className="grid grid-cols-2 gap-2">
212
+ <Select value={pageSize} onValueChange={(v) => { setPageSize(v); setPage(1); }}>
213
+ <SelectTrigger>
214
+ <SelectValue />
215
+ </SelectTrigger>
216
+ <SelectContent>
217
+ <SelectItem value="25">25 / page</SelectItem>
218
+ <SelectItem value="50">50 / page</SelectItem>
219
+ <SelectItem value="100">100 / page</SelectItem>
220
+ </SelectContent>
221
+ </Select>
222
+ <div className="text-xs text-slate-500 flex items-center justify-end">
223
+ {total} total contacts
224
+ </div>
225
+ </div>
226
  </>
227
  )}
228
  </div>
229
+ <div className="max-h-[62vh] overflow-y-auto pr-1 border border-slate-200 rounded-xl">
230
  {loading ? (
231
  <p className="text-sm text-slate-500 p-3">Loading contacts...</p>
232
  ) : contacts.length === 0 ? (
 
235
  <p>No contacts found</p>
236
  </div>
237
  ) : (
238
+ <table className="w-full text-sm">
239
+ <thead className="sticky top-0 bg-slate-50 z-10">
240
+ <tr className="text-left text-slate-500">
241
+ <th className="px-3 py-2 font-medium">Name</th>
242
+ <th className="px-3 py-2 font-medium">Email</th>
243
+ <th className="px-3 py-2 font-medium">Company</th>
244
+ </tr>
245
+ </thead>
246
+ <tbody>
247
+ {contacts.map((contact) => {
248
+ const active = selectedContact?.id === contact.id;
249
+ return (
250
+ <tr
251
+ key={contact.id}
252
+ onClick={() => openContact(contact)}
253
+ className={`cursor-pointer border-t ${
254
+ active ? 'bg-violet-50' : 'hover:bg-slate-50'
255
+ }`}
256
+ >
257
+ <td className="px-3 py-2 text-slate-800">{contact.first_name} {contact.last_name}</td>
258
+ <td className="px-3 py-2 text-slate-600 truncate max-w-[180px]">{contact.email || 'No email'}</td>
259
+ <td className="px-3 py-2 text-slate-600 truncate max-w-[160px]">{contact.company || 'No company'}</td>
260
+ </tr>
261
+ );
262
+ })}
263
+ </tbody>
264
+ </table>
265
  )}
266
  </div>
267
+ <div className="flex items-center justify-between mt-3">
268
+ <div className="text-xs text-slate-500">Page {page} of {totalPages}</div>
269
+ <div className="flex items-center gap-2">
270
+ <button
271
+ className="text-xs px-2 py-1 rounded border border-slate-200 disabled:opacity-40"
272
+ onClick={() => setPage((p) => Math.max(1, p - 1))}
273
+ disabled={page <= 1}
274
+ >
275
+ Prev
276
+ </button>
277
+ <button
278
+ className="text-xs px-2 py-1 rounded border border-slate-200 disabled:opacity-40"
279
+ onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
280
+ disabled={page >= totalPages}
281
+ >
282
+ Next
283
+ </button>
284
+ </div>
285
+ </div>
286
  </section>
287
 
288
  <section className="lg:col-span-3 rounded-2xl border border-slate-200 bg-white p-6">