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.