PageSourceSearch

https://www.pourify.com/assets/LeadsTable-BIew-F3c.js

js pourify.com collected 2026-10-04 19:35:57 UTC 23,436 bytes, 567 lines download raw bytes

1const e=`import { useState, useMemo } from "react";
2import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
3import { supabase } from "@/integrations/supabase/client";
4import { useAuth } from "@/hooks/useAuth";
5import {
6  Table, TableHeader, TableBody, TableHead, TableRow, TableCell,
7} from "@/components/ui/table";
8import { Badge } from "@/components/ui/badge";
9import { Button } from "@/components/ui/button";
10import { Input } from "@/components/ui/input";
11import { Textarea } from "@/components/ui/textarea";
12import { Checkbox } from "@/components/ui/checkbox";
13import {
14  Dialog, DialogContent, DialogHeader, DialogTitle,
15} from "@/components/ui/dialog";
16import {
17  AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
18  AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
19} from "@/components/ui/alert-dialog";
20import { Search, ChevronRight, Send, X, Plus, StickyNote, Tag, Trash2 } from "lucide-react";
21import { format } from "date-fns";
22import { toast } from "@/hooks/use-toast";
23
24type LeadType = "contact" | "demo" | "wholesale" | "roi" | "affiliate" | "referral";
25
26interface UnifiedLead {
27  id: string;
28  type: LeadType;
29  name: string;
30  email: string;
31  company?: string;
32  created_at: string;
33  raw: Record<string, any>;
34}
35
36const TYPE_LABELS: Record<LeadType, string> = {
37  contact: "Contact",
38  demo: "Demo Request",
39  wholesale: "Wholesale",
40  roi: "ROI Lead",
41  affiliate: "Affiliate",
42  referral: "Referred",
43};
44
45const TYPE_COLORS: Record<LeadType, string> = {
46  contact: "default",
47  demo: "secondary",
48  wholesale: "outline",
49  roi: "destructive",
50  affiliate: "default",
51  referral: "secondary",
52};
53
54const TABLE_MAP: Record<LeadType, string> = {
55  contact: "contact_submissions",
56  demo: "demo_requests",
57  wholesale: "wholesale_inquiries",
58  roi: "roi_leads",
59  affiliate: "affiliates",
60  referral: "referrals",
61};
62
63const PRESET_TAGS = ["Hot", "Warm", "Cold", "Follow-up", "VIP", "Enterprise", "Partner"];
64
65const fetchAllLeads = async (): Promise<UnifiedLead[]> => {
66  const [contacts, demos, wholesale, roi, affiliates, referrals] = await Promise.all([
67    supabase.from("contact_submissions").select("*").order("created_at", { ascending: false }),
68    supabase.from("demo_requests").select("*").order("created_at", { ascending: false }),
69    supabase.from("wholesale_inquiries").select("*").order("created_at", { ascending: false }),
70    supabase.from("roi_leads").select("*").order("created_at", { ascending: false }),
71    supabase.from("affiliates").select("*").order("created_at", { ascending: false }),
72    supabase.from("referrals").select("*").order("created_at", { ascending: false }),
73  ]);
74
75  const leads: UnifiedLead[] = [];
76  contacts.data?.forEach((c) =>
77    leads.push({ id: c.id, type: "contact", name: c.name, email: c.email, created_at: c.created_at, raw: c })
78  );
79  demos.data?.forEach((d) =>
80    leads.push({ id: d.id, type: "demo", name: d.name, email: d.email, company: d.company, created_at: d.created_at, raw: d })
81  );
82  wholesale.data?.forEach((w) =>
83    leads.push({ id: w.id, type: "wholesale", name: w.contact_name, email: w.email, company: w.company_name, created_at: w.created_at, raw: w })
84  );
85  roi.data?.forEach((r) =>
86    leads.push({ id: r.id, type: "roi", name: (r as any).contact_name || "-", email: r.email, created_at: r.created_at, raw: r })
87  );
88  affiliates.data?.forEach((a: any) =>
89    leads.push({ id: a.id, type: "affiliate", name: a.name || "-", email: a.email, created_at: a.created_at, raw: a })
90  );
91  referrals.data?.forEach((r: any) =>
92    leads.push({
93      id: r.id,
94      type: "referral",
95      name: r.lead_name || r.venue_name || "-",
96      email: r.lead_email || "-",
97      company: r.venue_name || undefined,
98      created_at: r.created_at,
99      raw: r,
100    })
101  );
102
103  return leads.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
104};
105
106const DETAIL_LABELS: Record<string, string> = {
107  name: "Name", email: "Email", company: "Company", company_name: "Company",
108  contact_name: "Contact", phone: "Phone", subject: "Subject", message: "Message",
109  country: "Country", pos_system: "POS System", pourers: "Pourers",
110  preferred_date: "Preferred Date", preferred_time: "Preferred Time",
111  website: "Website", estimated_volume: "Est. Volume", brands: "Brands",
112  net_saving: "Net Saving", region: "Region", venue_name: "Venue",
113  loss_percent: "Loss %", bottles_per_brand: "Bottles/Brand",
114  price_per_bottle: "Price/Bottle", annual_lost_revenue: "Annual Lost Revenue",
115  currency_symbol: "Currency", locale: "Locale", language: "Language",
116  monthly_software_cost: "Monthly Cost", pourify_annual_cost: "Annual Cost",
117  one_time_investment: "Setup Fee", annual_spillage_cost: "Spillage Cost",
118};
119
120interface LeadNote {
121  id: string;
122  content: string;
123  created_by: string;
124  created_at: string;
125}
126
127interface LeadTag {
128  id: string;
129  tag: string;
130}
131
132const LeadsTable = () => {
133  const { user, userRegions, metaLoaded } = useAuth();
134  const queryClient = useQueryClient();
135  const [filter, setFilter] = useState<LeadType | "all">("all");
136  const [search, setSearch] = useState("");
137  const [selected, setSelected] = useState<UnifiedLead | null>(null);
138  const [newNote, setNewNote] = useState("");
139  const [newTag, setNewTag] = useState("");
140  const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
141  const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
142
143  const { data: leads, isLoading } = useQuery({
144    queryKey: ["admin-leads"],
145    queryFn: fetchAllLeads,
146    enabled: metaLoaded,
147  });
148
149  const { data: notes } = useQuery<LeadNote[]>({
150    queryKey: ["lead-notes", selected?.id, selected?.type],
151    queryFn: async () => {
152      if (!selected) return [];
153      const { data, error } = await supabase
154        .from("lead_notes")
155        .select("id, content, created_by, created_at")
156        .eq("lead_id", selected.id)
157        .eq("lead_type", selected.type)
158        .order("created_at", { ascending: false });
159      if (error) throw error;
160      return data || [];
161    },
162    enabled: !!selected,
163  });
164
165  const { data: tags } = useQuery<LeadTag[]>({
166    queryKey: ["lead-tags", selected?.id, selected?.type],
167    queryFn: async () => {
168      if (!selected) return [];
169      const { data, error } = await supabase
170        .from("lead_tags")
171        .select("id, tag")
172        .eq("lead_id", selected.id)
173        .eq("lead_type", selected.type)
174        .order("created_at", { ascending: true });
175      if (error) throw error;
176      return data || [];
177    },
178    enabled: !!selected,
179  });
180
181  const { data: allTags } = useQuery<{ lead_id: string; lead_type: string; tag: string }[]>({
182    queryKey: ["all-lead-tags"],
183    queryFn: async () => {
184      const { data, error } = await supabase.from("lead_tags").select("lead_id, lead_type, tag");
185      if (error) throw error;
186      return data || [];
187    },
188  });
189
190  const tagsByLead = useMemo(() => {
191    const map = new Map<string, string[]>();
192    allTags?.forEach((t) => {
193      const key = \`\${t.lead_type}-\${t.lead_id}\`;
194      if (!map.has(key)) map.set(key, []);
195      map.get(key)!.push(t.tag);
196    });
197    return map;
198  }, [allTags]);
199
200  const addNote = useMutation({
201    mutationFn: async ({ leadId, leadType, content }: { leadId: string; leadType: string; content: string }) => {
202      const { error } = await supabase.from("lead_notes").insert({
203        lead_id: leadId, lead_type: leadType, content, created_by: user!.id,
204      });
205      if (error) throw error;
206    },
207    onSuccess: () => {
208      queryClient.invalidateQueries({ queryKey: ["lead-notes", selected?.id, selected?.type] });
209      setNewNote("");
210    },
211    onError: (err: any) => toast({ title: "Error", description: err.message, variant: "destructive" }),
212  });
213
214  const addTag = useMutation({
215    mutationFn: async ({ leadId, leadType, tag }: { leadId: string; leadType: string; tag: string }) => {
216      const { error } = await supabase.from("lead_tags").insert({ lead_id: leadId, lead_type: leadType, tag });
217      if (error) throw error;
218    },
219    onSuccess: () => {
220      queryClient.invalidateQueries({ queryKey: ["lead-tags", selected?.id, selected?.type] });
221      queryClient.invalidateQueries({ queryKey: ["all-lead-tags"] });
222      setNewTag("");
223    },
224    onError: (err: any) => toast({ title: "Error", description: err.message, variant: "destructive" }),
225  });
226
227  const removeTag = useMutation({
228    mutationFn: async (tagId: string) => {
229      const { error } = await supabase.from("lead_tags").delete().eq("id", tagId);
230      if (error) throw error;
231    },
232    onSuccess: () => {
233      queryClient.invalidateQueries({ queryKey: ["lead-tags", selected?.id, selected?.type] });
234      queryClient.invalidateQueries({ queryKey: ["all-lead-tags"] });
235    },
236  });
237
238  // Bulk delete mutation
239  const bulkDelete = useMutation({
240    mutationFn: async (leadsToDelete: UnifiedLead[]) => {
241      // Group by type
242      const grouped = new Map<LeadType, string[]>();
243      for (const lead of leadsToDelete) {
244        if (!grouped.has(lead.type)) grouped.set(lead.type, []);
245        grouped.get(lead.type)!.push(lead.id);
246      }
247
248      // Delete related data first
249      const allIds = leadsToDelete.map((l) => l.id);
250      if (allIds.length > 0) {
251        await supabase.from("lead_notes").delete().in("lead_id", allIds);
252        await supabase.from("lead_tags").delete().in("lead_id", allIds);
253        await supabase.from("lead_pipeline").delete().in("lead_id", allIds);
254      }
255
256      // Delete from source tables
257      for (const [type, ids] of grouped) {
258        const table = TABLE_MAP[type] as any;
259        await supabase.from(table).delete().in("id", ids);
260      }
261    },
262    onSuccess: () => {
263      queryClient.invalidateQueries({ queryKey: ["admin-leads"] });
264      queryClient.invalidateQueries({ queryKey: ["all-lead-tags"] });
265      queryClient.invalidateQueries({ queryKey: ["pipeline-leads"] });
266      setSelectedIds(new Set());
267      toast({ title: "Leads deleted", description: \`\${selectedIds.size} lead(s) have been removed.\` });
268    },
269    onError: (err: any) => toast({ title: "Delete failed", description: err.message, variant: "destructive" }),
270  });
271
272  // Region-filtered leads (before type/search filter)
273  const regionFiltered = useMemo(() => {
274    if (!leads) return [];
275    if (userRegions.length === 0) return leads;
276    return leads.filter((l) => {
277      const leadRegion = (l.raw.region || l.raw.country || "").toUpperCase();
278      return leadRegion && userRegions.some((r) => r.toUpperCase() === leadRegion);
279    });
280  }, [leads, userRegions]);
281
282  const filtered = useMemo(() => {
283    return regionFiltered.filter((l) => {
284      if (filter !== "all" && l.type !== filter) return false;
285      if (search) {
286        const q = search.toLowerCase();
287        return (
288          l.name.toLowerCase().includes(q) ||
289          l.email.toLowerCase().includes(q) ||
290          (l.company?.toLowerCase().includes(q) ?? false)
291        );
292      }
293      return true;
294    });
295  }, [regionFiltered, filter, search]);
296
297  const counts = useMemo(() => {
298    return {
299      all: regionFiltered.length,
300      contact: regionFiltered.filter((l) => l.type === "contact").length,
301      demo: regionFiltered.filter((l) => l.type === "demo").length,
302      wholesale: regionFiltered.filter((l) => l.type === "wholesale").length,
303      roi: regionFiltered.filter((l) => l.type === "roi").length,
304      affiliate: regionFiltered.filter((l) => l.type === "affiliate").length,
305      referral: regionFiltered.filter((l) => l.type === "referral").length,
306    };
307  }, [regionFiltered]);
308
309  const handleAddNote = () => {
310    if (!selected || !newNote.trim()) return;
311    addNote.mutate({ leadId: selected.id, leadType: selected.type, content: newNote.trim() });
312  };
313
314  const handleAddTag = (tag: string) => {
315    if (!selected || !tag.trim()) return;
316    if (tags?.some((t) => t.tag === tag.trim())) {
317      toast({ title: "Tag already added", variant: "destructive" });
318      return;
319    }
320    addTag.mutate({ leadId: selected.id, leadType: selected.type, tag: tag.trim() });
321  };
322
323  const toggleSelect = (key: string) => {
324    setSelectedIds((prev) => {
325      const next = new Set(prev);
326      if (next.has(key)) next.delete(key); else next.add(key);
327      return next;
328    });
329  };
330
331  const toggleSelectAll = () => {
332    if (selectedIds.size === filtered.length && filtered.length > 0) {
333      setSelectedIds(new Set());
334    } else {
335      setSelectedIds(new Set(filtered.map((l) => \`\${l.type}
335-\${l.id}\`)));
336    }
337  };
338
339  const handleBulkDelete = () => {
340    if (selectedIds.size === 0) return;
341    setDeleteDialogOpen(true);
342  };
343
344  const confirmBulkDelete = () => {
345    const leadsToDelete = (leads || []).filter((l) => selectedIds.has(\`\${l.type}-\${l.id}\`));
346    bulkDelete.mutate(leadsToDelete);
347    setDeleteDialogOpen(false);
348  };
349
350  if (isLoading) return <p className="text-muted-foreground py-8 text-center">Loading leads...</p>;
351
352  return (
353    <div className="space-y-4">
354      {/* Filters */}
355      <div className="flex flex-wrap items-center gap-2">
356        {(["all", "contact", "demo", "wholesale", "roi", "affiliate", "referral"] as const).map((t) => (
357          <Button key={t} variant={filter === t ? "default" : "outline"} size="sm" onClick={() => setFilter(t)}>
358            {t === "all" ? "All" : TYPE_LABELS[t]} ({counts[t]})
359          </Button>
360        ))}
361        <div className="relative ml-auto">
362          <Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
363          <Input placeholder="Search name, email, company..." value={search} onChange={(e) => setSearch(e.target.value)} className="pl-9 w-64" />
364        </div>
365      </div>
366
367      {/* Bulk action bar */}
368      {selectedIds.size > 0 && (
369        <div className="flex items-center gap-3 rounded-lg border border-destructive/30 bg-destructive/5 px-4 py-2">
370          <span className="text-sm font-medium text-foreground">{selectedIds.size} selected</span>
371          <Button variant="destructive" size="sm" onClick={handleBulkDelete} disabled={bulkDelete.isPending}>
372            <Trash2 className="h-4 w-4 mr-1" />
373            {bulkDelete.isPending ? "Deleting..." : "Delete"}
374          </Button>
375          <Button variant="ghost" size="sm" onClick={() => setSelectedIds(new Set())}>
376            Cancel
377          </Button>
378        </div>
379      )}
380
381      {/* Table */}
382      <div className="rounded-md border">
383        <Table>
384          <TableHeader>
385            <TableRow>
386              <TableHead className="w-10">
387                <Checkbox
388                  checked={filtered.length > 0 && selectedIds.size === filtered.length}
389                  onCheckedChange={toggleSelectAll}
390                />
391              </TableHead>
392              <TableHead>Date</TableHead>
393              <TableHead>Type</TableHead>
394              <TableHead>Region</TableHead>
395              <TableHead>Name</TableHead>
396              <TableHead>Email</TableHead>
397              <TableHead>Company</TableHead>
398              <TableHead>Tags</TableHead>
399              <TableHead className="w-10"></TableHead>
400            </TableRow>
401          </TableHeader>
402          <TableBody>
403            {filtered.length === 0 ? (
404              <TableRow>
405                <TableCell colSpan={9} className="text-center text-muted-foreground py-8">No leads found</TableCell>
406              </TableRow>
407            ) : (
408              filtered.map((lead) => {
409                const key = \`\${lead.type}-\${lead.id}\`;
410                const leadTags = tagsByLead.get(key) || [];
411                return (
412                  <TableRow key={key} className="cursor-pointer">
413                    <TableCell onClick={(e) => e.stopPropagation()}>
414                      <Checkbox
415                        checked={selectedIds.has(key)}
416                        onCheckedChange={() => toggleSelect(key)}
417                      />
418                    </TableCell>
419                    <TableCell className="whitespace-nowrap" onClick={() => setSelected(lead)}>{format(new Date(lead.created_at), "dd MMM yyyy")}</TableCell>
420                    <TableCell onClick={() => setSelected(lead)}><Badge variant={TYPE_COLORS[lead.type] as any}>{TYPE_LABELS[lead.type]}</Badge></TableCell>
421                    <TableCell onClick={() => setSelected(lead)}>
422                      {lead.raw.region || lead.raw.country || "-"}
423                    </TableCell>
424                    <TableCell onClick={() => setSelected(lead)}>{lead.name}</TableCell>
425                    <TableCell onClick={() => setSelected(lead)}>{lead.email}</TableCell>
426                    <TableCell onClick={() => setSelected(lead)}>
426{lead.company || "-"}</TableCell>
427                    <TableCell onClick={() => setSelected(lead)}>
428                      <div className="flex flex-wrap gap-1">
429                        {leadTags.map((tag) => (
430                          <Badge key={tag} variant="outline" className="text-xs">{tag}</Badge>
431                        ))}
432                      </div>
433                    </TableCell>
434                    <TableCell onClick={() => setSelected(lead)}><ChevronRight className="h-4 w-4 text-muted-foreground" /></TableCell>
435                  </TableRow>
436                );
437              })
438            )}
439          </TableBody>
440        </Table>
441      </div>
442
443      {/* Delete confirmation */}
444      <AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
445        <AlertDialogContent>
446          <AlertDialogHeader>
447            <AlertDialogTitle>Are you sure?</AlertDialogTitle>
448            <AlertDialogDescription>
449              You are about to permanently delete {selectedIds.size} lead(s) and all their associated notes, tags, and pipeline data. This action cannot be undone.
450            </AlertDialogDescription>
451          </AlertDialogHeader>
452          <AlertDialogFooter>
453            <AlertDialogCancel>Cancel</AlertDialogCancel>
454            <AlertDialogAction onClick={confirmBulkDelete} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
455              Delete {selectedIds.size} lead(s)
456            </AlertDialogAction>
457          </AlertDialogFooter>
458        </AlertDialogContent>
459      </AlertDialog>
460
461      {/* Detail dialog with notes & tags */}
462      <Dialog open={!!selected} onOpenChange={(open) => !open && setSelected(null)}>
463        <DialogContent className="max-w-2xl max-h-[85vh] overflow-y-auto">
464          <DialogHeader>
465            <DialogTitle className="flex items-center gap-2">
466              Lead Details
467              {selected && <Badge variant={TYPE_COLORS[selected.type] as any}>{TYPE_LABELS[selected.type]}</Badge>}
468            </DialogTitle>
469          </DialogHeader>
470          {selected && (
471            <div className="space-y-6">
472              <div className="space-y-2">
473                {Object.entries(selected.raw)
474                  .filter(([key]) => !["id", "created_at"].includes(key))
475                  .map(([key, value]) => (
476                    <div key={key} className="grid grid-cols-3 gap-2">
477                      <span className="text-sm font-medium text-muted-foreground">{DETAIL_LABELS[key] || key}</span>
478                      <span className="text-sm text-foreground col-span-2 break-words">{value?.toString() || "-"}</span>
479                    </div>
480                  ))}
481                <div className="grid grid-cols-3 gap-2 pt-2 border-t">
482                  <span className="text-sm font-medium text-muted-foreground">Submitted</span>
483                  <span className="text-sm text-foreground col-span-2">{format(new Date(selected.created_at), "dd MMM yyyy HH:mm")}</span>
484                </div>
485              </div>
486
487              {/* Tags section */}
488              <div className="space-y-3 border-t pt-4">
489                <div className="flex items-center gap-2">
490                  <Tag className="h-4 w-4 text-muted-foreground" />
491                  <h3 className="text-sm font-semibold text-foreground">Tags</h3>
492                </div>
493                <div className="flex flex-wrap gap-2">
494                  {tags?.map((t) => (
495                    <Badge key={t.id} variant="secondary" className="gap-1 pr-1">
496                      {t.tag}
497                      <button onClick={() => removeTag.mutate(t.id)} className="ml-1 rounded-full p-0.5 hover:bg-muted-foreground/20">
498                        <X className="h-3 w-3" />
499                      </button>
500                    </Badge>
501                  ))}
502                </div>
503                <div className="flex flex-wrap gap-1">
504                  {PRESET_TAGS.filter((pt) => !tags?.some((t) => t.tag === pt)).map((pt) => (
505                    <Button key={pt} variant="outline" size="sm" className="h-7 text-xs" onClick={() => handleAddTag(pt)}>
506                      <Plus className="h-3 w-3 mr-1" /> {pt}
507                    </Button>
508                  ))}
509                </div>
510                <div className="flex gap-2">
511                  <Input
512                    placeholder="Custom tag..."
513                    value={newTag}
514                    onChange={(e) => setNewTag(e.target.value)}
515                    className="flex-1"
516                    onKeyDown={(e) => { if (e.key === "Enter") handleAddTag(newTag); }}
517                  />
518                  <Button size="sm" variant="outline" onClick={() => handleAddTag(newTag)} disabled={!newTag.trim()}>
519                    <Plus className="h-4 w-4" />
520                  </Button>
521                </div>
522              </div>
523
524              {/* Notes section */}
525              <div className="space-y-3 border-t pt-4">
526                <div className="flex items-center gap-2">
527                  <StickyNote className="h-4 w-4 text-muted-foreground" />
528                  <h3 className="text-sm font-semibold text-foreground">Notes</h3>
529                </div>
530                <div className="flex gap-2">
531                  <Textarea
532                    placeholder="Add a note..."
533                    value={newNote}
534                    onChange={(e) => setNewNote(e.target.value)}
535                    rows={2}
536                    className="flex-1"
537                    onKeyDown={(e) => { if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) handleAddNote(); }}
538                  />
539                  <Button size="sm" onClick={handleAddNote} disabled={!newNote.trim() || addNote.isPending} className="self-end">
540                    <Send className="h-4 w-4" />
541                  </Button>
542                </div>
543                {notes && notes.length > 0 ? (
544                  <div className="space-y-2 max-h-60 overflow-y-auto">
545                    {notes.map((note) => (
546                      <div key={note.id} className="rounded-md border p-3 space-y-1">
547                        <p className="text-sm text-foreground whitespace-pre-wrap">{note.content}</p>
548                        <p className="text-xs text-muted-foreground">
549                          {format(new Date(note.created_at), "dd MMM yyyy HH:mm")}
550                        </p>
551                      </div>
552                    ))}
553                  </div>
554                ) : (
555                  <p className="text-sm text-muted-foreground">No notes yet</p>
556                )}
557              </div>
558            </div>
559          )}
560        </DialogContent>
561      </Dialog>
562    </div>
563  );
564};
565
566export default LeadsTable;
567`;export{e as default};

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.