Seth commited on
Commit
6953c2b
·
1 Parent(s): 8f4ffac
Files changed (2) hide show
  1. backend/app/main.py +292 -50
  2. frontend/src/pages/Contacts.jsx +204 -79
backend/app/main.py CHANGED
@@ -159,6 +159,52 @@ DEAL_STAGE_ALLOWED = frozenset({
159
  })
160
  SMARTLEAD_IMPORT_FILE_ID = "smartlead-imports"
161
  MANUAL_CONTACT_FILE_ID = "manual-contacts"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
162
 
163
 
164
  @app.on_event("startup")
@@ -533,7 +579,11 @@ async def contact_fields(db: Session = Depends(get_db)):
533
  @app.get("/api/contacts")
534
  async def list_contacts(
535
  search: str = Query("", description="Search by name/email/company/title"),
536
- field: str = Query("", description="Optional field name to filter"),
 
 
 
 
537
  value: str = Query("", description="Optional field value to filter"),
538
  op: str = Query("contains", description="Filter op: contains|equals|from|to|between"),
539
  from_value: str = Query("", description="From value for range filters"),
@@ -542,63 +592,44 @@ async def list_contacts(
542
  sort_dir: str = Query("desc", description="Sort direction: asc|desc"),
543
  limit: int = Query(200, ge=1, le=1000),
544
  offset: int = Query(0, ge=0),
545
- db: Session = Depends(get_db)
546
  ):
547
  query = db.query(Contact)
548
  if search:
549
  pattern = f"%{search}%"
550
  query = query.filter(
551
- (Contact.first_name.ilike(pattern)) |
552
- (Contact.last_name.ilike(pattern)) |
553
- (Contact.email.ilike(pattern)) |
554
- (Contact.company.ilike(pattern)) |
555
- (Contact.title.ilike(pattern))
556
  )
557
  contacts_all = query.order_by(Contact.created_at.desc(), Contact.id.desc()).all()
558
 
559
- # Dynamic field filtering for all imported Apollo attributes.
560
- if field:
561
- cmp_value = value or from_value
562
- v = _safe_str(cmp_value).lower()
563
- v_from = _safe_str(from_value)
564
- v_to = _safe_str(to_value)
565
-
566
- def match_dynamic(c: Contact):
567
- field_val = _contact_value(c, field)
568
- field_str = _safe_str(field_val).lower()
569
- op_local = (op or "contains").lower()
570
- if op_local == "equals":
571
- return field_str == v
572
- if op_local == "from":
573
- # Numeric/date first, then lexical fallback
574
- f_num, v_num = _to_float(field_val), _to_float(v_from or value)
575
- if f_num is not None and v_num is not None:
576
- return f_num >= v_num
577
- f_dt, v_dt = _to_datetime(field_val), _to_datetime(v_from or value)
578
- if f_dt and v_dt:
579
- return f_dt >= v_dt
580
- return field_str >= _safe_str(v_from or value).lower()
581
- if op_local == "to":
582
- f_num, v_num = _to_float(field_val), _to_float(v_to or value)
583
- if f_num is not None and v_num is not None:
584
- return f_num <= v_num
585
- f_dt, v_dt = _to_datetime(field_val), _to_datetime(v_to or value)
586
- if f_dt and v_dt:
587
- return f_dt <= v_dt
588
- return field_str <= _safe_str(v_to or value).lower()
589
- if op_local == "between":
590
- f_num, from_num, to_num = _to_float(field_val), _to_float(v_from), _to_float(v_to)
591
- if f_num is not None and from_num is not None and to_num is not None:
592
- return from_num <= f_num <= to_num
593
- f_dt, from_dt, to_dt = _to_datetime(field_val), _to_datetime(v_from), _to_datetime(v_to)
594
- if f_dt and from_dt and to_dt:
595
- return from_dt <= f_dt <= to_dt
596
- low, high = v_from.lower(), v_to.lower()
597
- return low <= field_str <= high
598
- # default contains
599
- return v in field_str
600
-
601
- contacts_all = [c for c in contacts_all if match_dynamic(c)]
602
 
603
  # Sort by mapped or raw Apollo fields
604
  reverse = (sort_dir or "desc").lower() != "asc"
@@ -700,6 +731,217 @@ async def bulk_delete_contacts(body: BulkContactIdsRequest, db: Session = Depend
700
  return {"deleted": deleted}
701
 
702
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
703
  @app.post("/api/contacts/bulk-convert-to-leads")
704
  async def bulk_convert_contacts_to_leads(body: BulkContactIdsRequest, db: Session = Depends(get_db)):
705
  """
 
159
  })
160
  SMARTLEAD_IMPORT_FILE_ID = "smartlead-imports"
161
  MANUAL_CONTACT_FILE_ID = "manual-contacts"
162
+ DEMO_CONTACTS_FILE_ID = "demo-contacts-crm"
163
+
164
+
165
+ def _contact_matches_apollo_filter(c: Contact, rule: dict) -> bool:
166
+ """Single dynamic-field rule (same semantics as legacy single-field query)."""
167
+ field = _safe_str(rule.get("field"))
168
+ if not field:
169
+ return True
170
+ op_local = (_safe_str(rule.get("op") or "contains")).lower()
171
+ value = _safe_str(rule.get("value"))
172
+ from_value = _safe_str(rule.get("from_value"))
173
+ to_value = _safe_str(rule.get("to_value"))
174
+ cmp_value = value or from_value
175
+ v = _safe_str(cmp_value).lower()
176
+ v_from = from_value
177
+ v_to = to_value
178
+ field_val = _contact_value(c, field)
179
+ field_str = _safe_str(field_val).lower()
180
+ if op_local == "equals":
181
+ return field_str == v
182
+ if op_local == "from":
183
+ f_num, v_num = _to_float(field_val), _to_float(v_from or value)
184
+ if f_num is not None and v_num is not None:
185
+ return f_num >= v_num
186
+ f_dt, v_dt = _to_datetime(field_val), _to_datetime(v_from or value)
187
+ if f_dt and v_dt:
188
+ return f_dt >= v_dt
189
+ return field_str >= _safe_str(v_from or value).lower()
190
+ if op_local == "to":
191
+ f_num, v_num = _to_float(field_val), _to_float(v_to or value)
192
+ if f_num is not None and v_num is not None:
193
+ return f_num <= v_num
194
+ f_dt, v_dt = _to_datetime(field_val), _to_datetime(v_to or value)
195
+ if f_dt and v_dt:
196
+ return f_dt <= v_dt
197
+ return field_str <= _safe_str(v_to or value).lower()
198
+ if op_local == "between":
199
+ f_num, from_num, to_num = _to_float(field_val), _to_float(v_from), _to_float(v_to)
200
+ if f_num is not None and from_num is not None and to_num is not None:
201
+ return from_num <= f_num <= to_num
202
+ f_dt, from_dt, to_dt = _to_datetime(field_val), _to_datetime(v_from), _to_datetime(v_to)
203
+ if f_dt and from_dt and to_dt:
204
+ return from_dt <= f_dt <= to_dt
205
+ low, high = v_from.lower(), v_to.lower()
206
+ return low <= field_str <= high
207
+ return v in field_str
208
 
209
 
210
  @app.on_event("startup")
 
579
  @app.get("/api/contacts")
580
  async def list_contacts(
581
  search: str = Query("", description="Search by name/email/company/title"),
582
+ filters: str = Query(
583
+ "",
584
+ description='JSON array of {field, op, value?, from_value?, to_value?}; combined with AND',
585
+ ),
586
+ field: str = Query("", description="Optional field name to filter (legacy single filter)"),
587
  value: str = Query("", description="Optional field value to filter"),
588
  op: str = Query("contains", description="Filter op: contains|equals|from|to|between"),
589
  from_value: str = Query("", description="From value for range filters"),
 
592
  sort_dir: str = Query("desc", description="Sort direction: asc|desc"),
593
  limit: int = Query(200, ge=1, le=1000),
594
  offset: int = Query(0, ge=0),
595
+ db: Session = Depends(get_db),
596
  ):
597
  query = db.query(Contact)
598
  if search:
599
  pattern = f"%{search}%"
600
  query = query.filter(
601
+ (Contact.first_name.ilike(pattern))
602
+ | (Contact.last_name.ilike(pattern))
603
+ | (Contact.email.ilike(pattern))
604
+ | (Contact.company.ilike(pattern))
605
+ | (Contact.title.ilike(pattern))
606
  )
607
  contacts_all = query.order_by(Contact.created_at.desc(), Contact.id.desc()).all()
608
 
609
+ # Multiple Apollo-field filters (AND). Prefer over legacy single-field params when non-empty.
610
+ if filters and filters.strip():
611
+ try:
612
+ rules = json.loads(filters)
613
+ except json.JSONDecodeError:
614
+ raise HTTPException(status_code=400, detail="Invalid filters JSON")
615
+ if not isinstance(rules, list):
616
+ raise HTTPException(status_code=400, detail="filters must be a JSON array")
617
+ active_rules = [r for r in rules if isinstance(r, dict) and _safe_str(r.get("field"))]
618
+ if active_rules:
619
+ contacts_all = [
620
+ c
621
+ for c in contacts_all
622
+ if all(_contact_matches_apollo_filter(c, r) for r in active_rules)
623
+ ]
624
+ elif field:
625
+ rule = {
626
+ "field": field,
627
+ "op": op,
628
+ "value": value,
629
+ "from_value": from_value,
630
+ "to_value": to_value,
631
+ }
632
+ contacts_all = [c for c in contacts_all if _contact_matches_apollo_filter(c, rule)]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
633
 
634
  # Sort by mapped or raw Apollo fields
635
  reverse = (sort_dir or "desc").lower() != "asc"
 
731
  return {"deleted": deleted}
732
 
733
 
734
+ @app.post("/api/contacts/seed-demo")
735
+ async def seed_demo_contacts(db: Session = Depends(get_db)):
736
+ """
737
+ Replace previous demo-seeded contacts and insert a variety of sample rows
738
+ (rich Apollo-style raw_data) for testing filters and UI.
739
+ """
740
+ removed = (
741
+ db.query(Contact)
742
+ .filter(Contact.file_id == DEMO_CONTACTS_FILE_ID)
743
+ .delete(synchronize_session=False)
744
+ )
745
+ specs = [
746
+ {
747
+ "fn": "Morgan",
748
+ "ln": "Lee",
749
+ "email": "morgan.lee@blueharbor.demo",
750
+ "co": "Blue Harbor Analytics",
751
+ "ti": "Chief Revenue Officer",
752
+ "raw": {
753
+ "Company Name": "Blue Harbor Analytics",
754
+ "Company Name for Emails": "BlueHarbor",
755
+ "Industry": "Business intelligence & analytics software",
756
+ "# Employees": "51-200",
757
+ "Annual Revenue": "$8,200,000",
758
+ "Last Raised At": "2023-11",
759
+ "Website": "https://www.blueharbor-analytics.demo",
760
+ "City": "Seattle",
761
+ "State": "WA",
762
+ "Country": "United States",
763
+ },
764
+ },
765
+ {
766
+ "fn": "Priya",
767
+ "ln": "Shah",
768
+ "email": "priya.shah@lumengrid.demo",
769
+ "co": "LumenGrid Energy",
770
+ "ti": "Director of Procurement",
771
+ "raw": {
772
+ "Company Name": "LumenGrid Energy",
773
+ "Industry": "Renewable energy infrastructure",
774
+ "# Employees": "201-500",
775
+ "Annual Revenue": "$45,000,000",
776
+ "Website": "https://lumengrid.demo",
777
+ "City": "Toronto",
778
+ "State": "ON",
779
+ "Country": "Canada",
780
+ },
781
+ },
782
+ {
783
+ "fn": "Diego",
784
+ "ln": "Ramos",
785
+ "email": "diego.ramos@vertexledger.demo",
786
+ "co": "Vertex Ledger",
787
+ "ti": "Controller",
788
+ "raw": {
789
+ "Company Name": "Vertex Ledger",
790
+ "Industry": "Enterprise document and content management software",
791
+ "# Employees": "11-50",
792
+ "Annual Revenue": "$3,100,000",
793
+ "Website": "https://vertexledger.demo",
794
+ "City": "Austin",
795
+ "State": "TX",
796
+ "Country": "United States",
797
+ },
798
+ },
799
+ {
800
+ "fn": "Elena",
801
+ "ln": "Volkov",
802
+ "email": "elena.volkov@northpole.demo",
803
+ "co": "Northpole Freight Co.",
804
+ "ti": "Operations Manager",
805
+ "raw": {
806
+ "Company Name": "Northpole Freight Co.",
807
+ "Industry": "Logistics & Supply Chain",
808
+ "# Employees": "501-1000",
809
+ "Annual Revenue": "$120,000,000",
810
+ "Website": "https://northpole-freight.demo",
811
+ "City": "Hamburg",
812
+ "State": "",
813
+ "Country": "Germany",
814
+ },
815
+ },
816
+ {
817
+ "fn": "James",
818
+ "ln": "Okonkwo",
819
+ "email": "james.okonkwo@silverfin.demo",
820
+ "co": "Silverfin Capital",
821
+ "ti": "Investment Analyst",
822
+ "raw": {
823
+ "Company Name": "Silverfin Capital",
824
+ "Industry": "Financial services",
825
+ "# Employees": "51-200",
826
+ "Annual Revenue": "$22,500,000",
827
+ "Website": "https://silverfin.demo",
828
+ "City": "London",
829
+ "State": "",
830
+ "Country": "United Kingdom",
831
+ },
832
+ },
833
+ {
834
+ "fn": "Yuki",
835
+ "ln": "Tanaka",
836
+ "email": "yuki.tanaka@skyforge.demo",
837
+ "co": "Skyforge Robotics",
838
+ "ti": "Head of Engineering",
839
+ "raw": {
840
+ "Company Name": "Skyforge Robotics",
841
+ "Industry": "Industrial automation",
842
+ "# Employees": "201-500",
843
+ "Annual Revenue": "$67,000,000",
844
+ "Website": "https://skyforge.demo",
845
+ "City": "Osaka",
846
+ "State": "",
847
+ "Country": "Japan",
848
+ },
849
+ },
850
+ {
851
+ "fn": "Amira",
852
+ "ln": "Hassan",
853
+ "email": "amira.hassan@desertwave.demo",
854
+ "co": "Desert Wave Telecom",
855
+ "ti": "VP Customer Success",
856
+ "raw": {
857
+ "Company Name": "Desert Wave Telecom",
858
+ "Industry": "Telecommunications",
859
+ "# Employees": "1001-5000",
860
+ "Annual Revenue": "$410,000,000",
861
+ "Website": "https://desertwave.demo",
862
+ "City": "Dubai",
863
+ "State": "",
864
+ "Country": "United Arab Emirates",
865
+ },
866
+ },
867
+ {
868
+ "fn": "Chris",
869
+ "ln": "Martinez",
870
+ "email": "chris.martinez@cascade.demo",
871
+ "co": "Cascade Health Systems",
872
+ "ti": "Clinical Director",
873
+ "raw": {
874
+ "Company Name": "Cascade Health Systems",
875
+ "Industry": "Healthcare IT",
876
+ "# Employees": "501-1000",
877
+ "Annual Revenue": "$95,000,000",
878
+ "Website": "https://cascade-health.demo",
879
+ "City": "Denver",
880
+ "State": "CO",
881
+ "Country": "United States",
882
+ },
883
+ },
884
+ {
885
+ "fn": "Sofia",
886
+ "ln": "Andersson",
887
+ "email": "sofia.andersson@fjord.demo",
888
+ "co": "Fjord Maritime AS",
889
+ "ti": "Fleet Coordinator",
890
+ "raw": {
891
+ "Company Name": "Fjord Maritime AS",
892
+ "Industry": "Shipping & maritime",
893
+ "# Employees": "51-200",
894
+ "Annual Revenue": "$18,000,000",
895
+ "Website": "https://fjord-maritime.demo",
896
+ "City": "Bergen",
897
+ "State": "",
898
+ "Country": "Norway",
899
+ },
900
+ },
901
+ {
902
+ "fn": "Marcus",
903
+ "ln": "Webbe",
904
+ "email": "marcus.webbe@pixelloft.demo",
905
+ "co": "Pixelloft Creative Agency",
906
+ "ti": "Creative Director",
907
+ "raw": {
908
+ "Company Name": "Pixelloft Creative Agency",
909
+ "Industry": "Marketing & advertising",
910
+ "# Employees": "11-50",
911
+ "Annual Revenue": "$1,800,000",
912
+ "Website": "https://pixelloft.demo",
913
+ "City": "Melbourne",
914
+ "State": "VIC",
915
+ "Country": "Australia",
916
+ },
917
+ },
918
+ ]
919
+ for i, s in enumerate(specs):
920
+ rd = dict(s["raw"])
921
+ rd["Title"] = s["ti"]
922
+ rd["Email"] = s["email"]
923
+ rd["First Name"] = s["fn"]
924
+ rd["Last Name"] = s["ln"]
925
+ row = Contact(
926
+ file_id=DEMO_CONTACTS_FILE_ID,
927
+ row_index=i,
928
+ first_name=s["fn"],
929
+ last_name=s["ln"],
930
+ email=s["email"],
931
+ company=s["co"],
932
+ title=s["ti"],
933
+ source="demo",
934
+ raw_data=rd,
935
+ )
936
+ db.add(row)
937
+ db.commit()
938
+ return {
939
+ "ok": True,
940
+ "removed_previous_demo_rows": removed,
941
+ "inserted": len(specs),
942
+ }
943
+
944
+
945
  @app.post("/api/contacts/bulk-convert-to-leads")
946
  async def bulk_convert_contacts_to_leads(body: BulkContactIdsRequest, db: Session = Depends(get_db)):
947
  """
frontend/src/pages/Contacts.jsx CHANGED
@@ -13,6 +13,7 @@ import {
13
  Trash2,
14
  Handshake,
15
  Pencil,
 
16
  } from 'lucide-react';
17
  import { Input } from '@/components/ui/input';
18
  import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
@@ -25,6 +26,17 @@ import ContactIdentityEditor from '@/components/workspace/ContactIdentityEditor'
25
  import { EditableCell } from '@/components/workspace/EditableCell';
26
  import { cn } from '@/lib/utils';
27
 
 
 
 
 
 
 
 
 
 
 
 
28
  export default function Contacts() {
29
  const navigate = useNavigate();
30
  const [contacts, setContacts] = useState([]);
@@ -35,11 +47,8 @@ export default function Contacts() {
35
  const [loading, setLoading] = useState(true);
36
  const [seqLoading, setSeqLoading] = useState(false);
37
  const [searchQuery, setSearchQuery] = useState('');
38
- const [filterField, setFilterField] = useState('none');
39
- const [filterOp, setFilterOp] = useState('contains');
40
- const [filterValue, setFilterValue] = useState('');
41
- const [filterFrom, setFilterFrom] = useState('');
42
- const [filterTo, setFilterTo] = useState('');
43
  const [sortBy, setSortBy] = useState('created_at');
44
  const [sortDir, setSortDir] = useState('desc');
45
  const [page, setPage] = useState(1);
@@ -88,6 +97,25 @@ export default function Contacts() {
88
 
89
  const allPageSelected = contacts.length > 0 && contacts.every((c) => rowSelection[c.id]);
90
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
91
  useEffect(() => {
92
  fetchFields();
93
  fetchContacts();
@@ -117,11 +145,11 @@ export default function Contacts() {
117
  useEffect(() => {
118
  const timer = setTimeout(() => fetchContacts(), 250);
119
  return () => clearTimeout(timer);
120
- }, [searchQuery, filterField, filterOp, filterValue, filterFrom, filterTo, sortBy, sortDir, page, pageSize]);
121
 
122
  useEffect(() => {
123
  setPage(1);
124
- }, [searchQuery, filterField, filterOp, filterValue, filterFrom, filterTo, sortBy, sortDir, pageSize]);
125
 
126
  const fetchFields = async () => {
127
  try {
@@ -167,16 +195,17 @@ export default function Contacts() {
167
  params.set('sort_by', sortBy);
168
  params.set('sort_dir', sortDir);
169
  if (searchQuery.trim()) params.set('search', searchQuery.trim());
170
- if (filterField !== 'none') {
171
- params.set('field', filterField);
172
- if (filterFrom.trim() || filterTo.trim()) {
173
- params.set('op', 'between');
174
- if (filterFrom.trim()) params.set('from_value', filterFrom.trim());
175
- if (filterTo.trim()) params.set('to_value', filterTo.trim());
176
- } else {
177
- params.set('op', filterOp);
178
- params.set('value', filterValue.trim());
179
- }
 
180
  }
181
  const res = await fetch(`/api/contacts?${params.toString()}`);
182
  if (res.ok) {
@@ -191,6 +220,28 @@ export default function Contacts() {
191
  }
192
  };
193
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
194
  const openContact = async (contact) => {
195
  setTableEditRowId(null);
196
  setSelectedContact(contact);
@@ -522,64 +573,109 @@ export default function Contacts() {
522
 
523
  const filtersBlock = (
524
  <div className="space-y-3">
525
- <div className="flex items-center text-xs text-slate-500 gap-1.5 font-medium">
526
- <SlidersHorizontal className="h-3.5 w-3.5" />
527
- Filter by any Apollo field
 
 
 
 
 
 
528
  </div>
529
- <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
530
- <Select value={filterField} onValueChange={setFilterField}>
531
- <SelectTrigger className="border-slate-200 bg-white">
532
- <SelectValue placeholder="Choose field" />
533
- </SelectTrigger>
534
- <SelectContent>
535
- <SelectItem value="none">No field filter</SelectItem>
536
- {fields.map((field) => (
537
- <SelectItem key={field} value={field}>
538
- {field}
539
- </SelectItem>
540
- ))}
541
- </SelectContent>
542
- </Select>
543
- {filterField !== 'none' && (
544
- <>
545
- <Select value={filterOp} onValueChange={setFilterOp}>
546
- <SelectTrigger className="border-slate-200 bg-white">
547
- <SelectValue />
548
- </SelectTrigger>
549
- <SelectContent>
550
- <SelectItem value="contains">contains</SelectItem>
551
- <SelectItem value="equals">equals</SelectItem>
552
- <SelectItem value="from">from</SelectItem>
553
- <SelectItem value="to">to</SelectItem>
554
- <SelectItem value="between">between</SelectItem>
555
- </SelectContent>
556
- </Select>
557
- {(filterOp === 'contains' || filterOp === 'equals') && (
558
- <Input
559
- className="border-slate-200"
560
- placeholder={`Value for "${filterField}"`}
561
- value={filterValue}
562
- onChange={(e) => setFilterValue(e.target.value)}
563
- />
564
- )}
565
- {(filterOp === 'from' || filterOp === 'to' || filterOp === 'between') && (
566
- <div className="flex gap-2 sm:col-span-2">
567
- <Input
568
- className="border-slate-200"
569
- placeholder="From"
570
- value={filterFrom}
571
- onChange={(e) => setFilterFrom(e.target.value)}
572
- />
573
- <Input
574
- className="border-slate-200"
575
- placeholder="To"
576
- value={filterTo}
577
- onChange={(e) => setFilterTo(e.target.value)}
578
- />
579
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
580
  )}
581
- </>
582
- )}
583
  </div>
584
  </div>
585
  );
@@ -597,9 +693,24 @@ export default function Contacts() {
597
  placeholder: 'Search contacts…',
598
  }}
599
  right={
600
- <Button variant="outline" size="sm" onClick={() => fetchContacts()}>
601
- Refresh
602
- </Button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
603
  }
604
  filters={filtersBlock}
605
  sectionIcon={Users}
@@ -618,9 +729,23 @@ export default function Contacts() {
618
  <div className="text-center py-16 text-slate-500 space-y-3">
619
  <Users className="h-10 w-10 mx-auto opacity-40" />
620
  <p>No contacts found</p>
621
- <Button variant="outline" size="sm" onClick={beginAddContact}>
622
- New contact
623
- </Button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
624
  </div>
625
  ) : (
626
  <>
 
13
  Trash2,
14
  Handshake,
15
  Pencil,
16
+ Plus,
17
  } from 'lucide-react';
18
  import { Input } from '@/components/ui/input';
19
  import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
 
26
  import { EditableCell } from '@/components/workspace/EditableCell';
27
  import { cn } from '@/lib/utils';
28
 
29
+ function makeFilterRow() {
30
+ return {
31
+ id: `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
32
+ field: 'none',
33
+ op: 'contains',
34
+ value: '',
35
+ fromVal: '',
36
+ toVal: '',
37
+ };
38
+ }
39
+
40
  export default function Contacts() {
41
  const navigate = useNavigate();
42
  const [contacts, setContacts] = useState([]);
 
47
  const [loading, setLoading] = useState(true);
48
  const [seqLoading, setSeqLoading] = useState(false);
49
  const [searchQuery, setSearchQuery] = useState('');
50
+ const [filterRows, setFilterRows] = useState(() => [makeFilterRow()]);
51
+ const [demoBusy, setDemoBusy] = useState(false);
 
 
 
52
  const [sortBy, setSortBy] = useState('created_at');
53
  const [sortDir, setSortDir] = useState('desc');
54
  const [page, setPage] = useState(1);
 
97
 
98
  const allPageSelected = contacts.length > 0 && contacts.every((c) => rowSelection[c.id]);
99
 
100
+ const filtersKey = useMemo(() => JSON.stringify(filterRows), [filterRows]);
101
+
102
+ const updateFilterRow = (id, patch) => {
103
+ setFilterRows((rows) => rows.map((r) => (r.id === id ? { ...r, ...patch } : r)));
104
+ };
105
+
106
+ const addFilterRow = () => {
107
+ setFilterRows((rows) => [...rows, makeFilterRow()]);
108
+ };
109
+
110
+ const removeFilterRow = (id) => {
111
+ setFilterRows((rows) => {
112
+ if (rows.length <= 1) {
113
+ return [makeFilterRow()];
114
+ }
115
+ return rows.filter((r) => r.id !== id);
116
+ });
117
+ };
118
+
119
  useEffect(() => {
120
  fetchFields();
121
  fetchContacts();
 
145
  useEffect(() => {
146
  const timer = setTimeout(() => fetchContacts(), 250);
147
  return () => clearTimeout(timer);
148
+ }, [searchQuery, filtersKey, sortBy, sortDir, page, pageSize]);
149
 
150
  useEffect(() => {
151
  setPage(1);
152
+ }, [searchQuery, filtersKey, sortBy, sortDir, pageSize]);
153
 
154
  const fetchFields = async () => {
155
  try {
 
195
  params.set('sort_by', sortBy);
196
  params.set('sort_dir', sortDir);
197
  if (searchQuery.trim()) params.set('search', searchQuery.trim());
198
+ const activeFilters = filterRows
199
+ .filter((r) => r.field && r.field !== 'none')
200
+ .map((r) => ({
201
+ field: r.field,
202
+ op: r.op,
203
+ value: r.value,
204
+ from_value: r.fromVal,
205
+ to_value: r.toVal,
206
+ }));
207
+ if (activeFilters.length > 0) {
208
+ params.set('filters', JSON.stringify(activeFilters));
209
  }
210
  const res = await fetch(`/api/contacts?${params.toString()}`);
211
  if (res.ok) {
 
220
  }
221
  };
222
 
223
+ const seedDemoContacts = async () => {
224
+ setDemoBusy(true);
225
+ try {
226
+ const res = await fetch('/api/contacts/seed-demo', { method: 'POST' });
227
+ const data = await res.json().catch(() => ({}));
228
+ if (!res.ok) {
229
+ throw new Error(typeof data.detail === 'string' ? data.detail : 'Could not load demo data');
230
+ }
231
+ await fetchFields();
232
+ await fetchContacts();
233
+ const n = data.inserted ?? 0;
234
+ if (n > 0) {
235
+ alert(`Loaded ${n} demo contact${n === 1 ? '' : 's'} with varied Apollo-style fields.`);
236
+ }
237
+ } catch (e) {
238
+ console.error(e);
239
+ alert(e.message || 'Could not load demo data');
240
+ } finally {
241
+ setDemoBusy(false);
242
+ }
243
+ };
244
+
245
  const openContact = async (contact) => {
246
  setTableEditRowId(null);
247
  setSelectedContact(contact);
 
573
 
574
  const filtersBlock = (
575
  <div className="space-y-3">
576
+ <div className="flex flex-wrap items-center justify-between gap-2">
577
+ <div className="flex items-center text-xs text-slate-500 gap-1.5 font-medium">
578
+ <SlidersHorizontal className="h-3.5 w-3.5" />
579
+ Filter by any Apollo field (combined with AND)
580
+ </div>
581
+ <Button type="button" variant="outline" size="sm" className="gap-1" onClick={addFilterRow}>
582
+ <Plus className="h-3.5 w-3.5" />
583
+ Add filter
584
+ </Button>
585
  </div>
586
+ <div className="space-y-3">
587
+ {filterRows.map((row) => (
588
+ <div
589
+ key={row.id}
590
+ className="grid grid-cols-1 gap-3 lg:grid-cols-12 lg:items-end lg:gap-2 rounded-xl border border-slate-100 bg-slate-50/50 p-3"
591
+ >
592
+ <div className="lg:col-span-3">
593
+ <label className="sr-only">Field</label>
594
+ <Select
595
+ value={row.field}
596
+ onValueChange={(v) => updateFilterRow(row.id, { field: v })}
597
+ >
598
+ <SelectTrigger className="border-slate-200 bg-white">
599
+ <SelectValue placeholder="Field" />
600
+ </SelectTrigger>
601
+ <SelectContent>
602
+ <SelectItem value="none">No field filter</SelectItem>
603
+ {fields.map((field) => (
604
+ <SelectItem key={field} value={field}>
605
+ {field}
606
+ </SelectItem>
607
+ ))}
608
+ </SelectContent>
609
+ </Select>
610
+ </div>
611
+ {row.field !== 'none' && (
612
+ <>
613
+ <div className="lg:col-span-2">
614
+ <label className="sr-only">Operator</label>
615
+ <Select
616
+ value={row.op}
617
+ onValueChange={(v) => updateFilterRow(row.id, { op: v })}
618
+ >
619
+ <SelectTrigger className="border-slate-200 bg-white">
620
+ <SelectValue />
621
+ </SelectTrigger>
622
+ <SelectContent>
623
+ <SelectItem value="contains">contains</SelectItem>
624
+ <SelectItem value="equals">equals</SelectItem>
625
+ <SelectItem value="from">from</SelectItem>
626
+ <SelectItem value="to">to</SelectItem>
627
+ <SelectItem value="between">between</SelectItem>
628
+ </SelectContent>
629
+ </Select>
630
+ </div>
631
+ {(row.op === 'contains' || row.op === 'equals') && (
632
+ <div className="lg:col-span-6">
633
+ <Input
634
+ className="border-slate-200 bg-white"
635
+ placeholder={`Value for “${row.field}”`}
636
+ value={row.value}
637
+ onChange={(e) =>
638
+ updateFilterRow(row.id, { value: e.target.value })
639
+ }
640
+ />
641
+ </div>
642
+ )}
643
+ {(row.op === 'from' || row.op === 'to' || row.op === 'between') && (
644
+ <div className="flex gap-2 lg:col-span-6">
645
+ <Input
646
+ className="border-slate-200 bg-white flex-1"
647
+ placeholder="From"
648
+ value={row.fromVal}
649
+ onChange={(e) =>
650
+ updateFilterRow(row.id, { fromVal: e.target.value })
651
+ }
652
+ />
653
+ <Input
654
+ className="border-slate-200 bg-white flex-1"
655
+ placeholder="To"
656
+ value={row.toVal}
657
+ onChange={(e) =>
658
+ updateFilterRow(row.id, { toVal: e.target.value })
659
+ }
660
+ />
661
+ </div>
662
+ )}
663
+ <div className="flex lg:col-span-1 lg:justify-end">
664
+ <Button
665
+ type="button"
666
+ variant="ghost"
667
+ size="icon"
668
+ className="text-slate-400 hover:text-red-600"
669
+ aria-label="Remove filter row"
670
+ onClick={() => removeFilterRow(row.id)}
671
+ >
672
+ <Trash2 className="h-4 w-4" />
673
+ </Button>
674
+ </div>
675
+ </>
676
  )}
677
+ </div>
678
+ ))}
679
  </div>
680
  </div>
681
  );
 
693
  placeholder: 'Search contacts…',
694
  }}
695
  right={
696
+ <div className="flex flex-wrap items-center gap-2 justify-end">
697
+ <Button
698
+ type="button"
699
+ variant="outline"
700
+ size="sm"
701
+ onClick={seedDemoContacts}
702
+ disabled={demoBusy}
703
+ className="gap-1.5"
704
+ >
705
+ {demoBusy ? (
706
+ <Loader2 className="h-4 w-4 animate-spin shrink-0" aria-hidden />
707
+ ) : null}
708
+ Load demo data
709
+ </Button>
710
+ <Button variant="outline" size="sm" onClick={() => fetchContacts()}>
711
+ Refresh
712
+ </Button>
713
+ </div>
714
  }
715
  filters={filtersBlock}
716
  sectionIcon={Users}
 
729
  <div className="text-center py-16 text-slate-500 space-y-3">
730
  <Users className="h-10 w-10 mx-auto opacity-40" />
731
  <p>No contacts found</p>
732
+ <div className="flex flex-wrap items-center justify-center gap-2">
733
+ <Button
734
+ type="button"
735
+ variant="outline"
736
+ size="sm"
737
+ onClick={seedDemoContacts}
738
+ disabled={demoBusy}
739
+ >
740
+ {demoBusy ? (
741
+ <Loader2 className="h-4 w-4 animate-spin mr-2" aria-hidden />
742
+ ) : null}
743
+ Load demo data
744
+ </Button>
745
+ <Button variant="outline" size="sm" onClick={beginAddContact}>
746
+ New contact
747
+ </Button>
748
+ </div>
749
  </div>
750
  ) : (
751
  <>