Add Lead Scoring ML + Email Sequences/Drip Campaigns
Build & Auto-Repair / build (push) Has been cancelled
Build & Auto-Repair / build (push) Has been cancelled
Lead Scoring:
- lead_scoring_config table with configurable feature weights
- Scoring engine: boolean, keyword, and range-based feature scoring
- Settings tab: configure scoring features, run scoring on all leads
- ml_score + ml_score_details columns on leads table
- API: /api/leads/score, /api/leads/score/config
- mlScore returned in lead detail and list APIs
Email Campaigns:
- email_campaigns, email_campaign_steps, subscribers, logs tables
- Campaign list page with status badges and subscriber counts
- Campaign detail with step management, subscriber search/add, send
- Drip logic: steps with configurable delay days, template variables
- Variable replacement ({{name}}, {{email}}) in email bodies
- Sidebar: Campaigns nav item
This commit is contained in:
@@ -0,0 +1,258 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { use } from "react"
|
||||
import { PageHeader } from "@/components/shared/page-header"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import {
|
||||
ArrowLeft, Plus, Trash2, Send, Play, Pause, Users, Mail, Clock,
|
||||
} from "lucide-react"
|
||||
|
||||
export default function CampaignDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = use(params)
|
||||
const router = useRouter()
|
||||
const [campaign, setCampaign] = useState<any>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [newStep, setNewStep] = useState({ subject: "", bodyText: "", delayDays: 3 })
|
||||
const [addingStep, setAddingStep] = useState(false)
|
||||
const [sending, setSending] = useState(false)
|
||||
const [leadSearch, setLeadSearch] = useState("")
|
||||
const [searchResults, setSearchResults] = useState<any[]>([])
|
||||
const [subscribing, setSubscribing] = useState(false)
|
||||
|
||||
const fetchCampaign = async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/campaigns/${id}`)
|
||||
if (res.ok) setCampaign(await res.json())
|
||||
} catch { /* ignore */ }
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
useEffect(() => { fetchCampaign() }, [id])
|
||||
|
||||
const searchLeads = async (q: string) => {
|
||||
setLeadSearch(q)
|
||||
if (q.length < 2) { setSearchResults([]); return }
|
||||
const res = await fetch(`/api/leads?search=${encodeURIComponent(q)}&limit=10`)
|
||||
if (res.ok) setSearchResults(await res.json())
|
||||
}
|
||||
|
||||
const addStep = async () => {
|
||||
if (!newStep.subject.trim()) return
|
||||
setAddingStep(true)
|
||||
try {
|
||||
await fetch(`/api/campaigns/${id}/steps`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(newStep),
|
||||
})
|
||||
setNewStep({ subject: "", bodyText: "", delayDays: 3 })
|
||||
fetchCampaign()
|
||||
} catch { /* ignore */ }
|
||||
setAddingStep(false)
|
||||
}
|
||||
|
||||
const subscribeLeads = async (leadIds: string[]) => {
|
||||
setSubscribing(true)
|
||||
try {
|
||||
await fetch(`/api/campaigns/${id}/subscribe`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ leadIds }),
|
||||
})
|
||||
setLeadSearch("")
|
||||
setSearchResults([])
|
||||
fetchCampaign()
|
||||
} catch { /* ignore */ }
|
||||
setSubscribing(false)
|
||||
}
|
||||
|
||||
const sendCampaign = async () => {
|
||||
setSending(true)
|
||||
try {
|
||||
const res = await fetch(`/api/campaigns/${id}/send`, { method: "POST" })
|
||||
const data = await res.json()
|
||||
if (data.sent > 0) fetchCampaign()
|
||||
} catch { /* ignore */ }
|
||||
setSending(false)
|
||||
}
|
||||
|
||||
const updateStatus = async (status: string) => {
|
||||
await fetch(`/api/campaigns/${id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ status }),
|
||||
})
|
||||
fetchCampaign()
|
||||
}
|
||||
|
||||
if (loading) return <div className="p-8 text-center text-muted-foreground">Loading...</div>
|
||||
if (!campaign) return <div className="p-8 text-center text-muted-foreground">Campaign not found</div>
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title={campaign.name}
|
||||
description={campaign.description || "Email campaign"}
|
||||
>
|
||||
<Button variant="outline" size="sm" onClick={() => router.push("/campaigns")}>
|
||||
<ArrowLeft className="mr-1.5 h-4 w-4" /> Back
|
||||
</Button>
|
||||
</PageHeader>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
<Card className="p-4 space-y-1">
|
||||
<p className="text-xs text-muted-foreground">Status</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline">{campaign.status}</Badge>
|
||||
{campaign.status === "draft" && <Button size="sm" variant="outline" className="h-6 text-xs" onClick={() => updateStatus("active")}><Play className="h-3 w-3 mr-1" />Activate</Button>}
|
||||
{campaign.status === "active" && <Button size="sm" variant="outline" className="h-6 text-xs" onClick={() => updateStatus("paused")}><Pause className="h-3 w-3 mr-1" />Pause</Button>}
|
||||
{campaign.status === "paused" && <Button size="sm" variant="outline" className="h-6 text-xs" onClick={() => updateStatus("active")}><Play className="h-3 w-3 mr-1" />Resume</Button>}
|
||||
</div>
|
||||
</Card>
|
||||
<Card className="p-4 space-y-1">
|
||||
<p className="text-xs text-muted-foreground">Subscribers</p>
|
||||
<p className="text-2xl font-bold flex items-center gap-2"><Users className="h-5 w-5 text-muted-foreground" />{campaign.subscribers?.length || 0}</p>
|
||||
</Card>
|
||||
<Card className="p-4 space-y-1">
|
||||
<p className="text-xs text-muted-foreground">Steps</p>
|
||||
<p className="text-2xl font-bold flex items-center gap-2"><Layers className="h-5 w-5 text-muted-foreground" />{campaign.steps?.length || 0}</p>
|
||||
</Card>
|
||||
<Card className="p-4 space-y-1">
|
||||
<p className="text-xs text-muted-foreground">Created</p>
|
||||
<p className="text-sm font-medium">{new Date(campaign.created_at).toLocaleDateString()}</p>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="steps">
|
||||
<TabsList>
|
||||
<TabsTrigger value="steps">Steps ({campaign.steps?.length || 0})</TabsTrigger>
|
||||
<TabsTrigger value="subscribers">Subscribers ({campaign.subscribers?.length || 0})</TabsTrigger>
|
||||
<TabsTrigger value="send">Send</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="steps" className="space-y-4">
|
||||
<Card className="p-4 space-y-3">
|
||||
<p className="text-sm font-medium">Add Step</p>
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">Subject *</label>
|
||||
<Input value={newStep.subject} onChange={e => setNewStep(p => ({ ...p, subject: e.target.value }))} placeholder="Follow-up: {{name}}" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">Delay (days)</label>
|
||||
<Input type="number" min="0" value={newStep.delayDays} onChange={e => setNewStep(p => ({ ...p, delayDays: parseInt(e.target.value) || 0 }))} />
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<Button size="sm" onClick={addStep} disabled={addingStep || !newStep.subject.trim()}>
|
||||
<Plus className="h-3.5 w-3.5 mr-1" /> Add Step
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">Email Body (use {{name}} for recipient name)</label>
|
||||
<textarea value={newStep.bodyText} onChange={e => setNewStep(p => ({ ...p, bodyText: e.target.value }))} className="flex min-h-[80px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm" placeholder="Hi {{name}}, We'd love to help with..." />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{campaign.steps?.length === 0 ? (
|
||||
<Card className="p-8 text-center text-muted-foreground">
|
||||
<p>No steps defined yet. Add your first email above.</p>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{campaign.steps.map((step: any, i: number) => (
|
||||
<Card key={step.id} className="p-4 flex items-start gap-4">
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary/10 text-sm font-bold text-primary">
|
||||
{i + 1}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium">{step.subject}</p>
|
||||
{step.body_text && <p className="text-xs text-muted-foreground mt-1 line-clamp-2">{step.body_text}</p>}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground shrink-0">
|
||||
<Clock className="h-3 w-3" />
|
||||
{step.delay_days > 0 ? `Wait ${step.delay_days}d` : "Immediate"}
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="subscribers" className="space-y-4">
|
||||
<Card className="p-4">
|
||||
<p className="text-sm font-medium mb-2">Add Leads to Campaign</p>
|
||||
<div className="flex gap-2">
|
||||
<Input placeholder="Search leads by name or company..." value={leadSearch} onChange={e => searchLeads(e.target.value)} className="flex-1" />
|
||||
</div>
|
||||
{searchResults.length > 0 && (
|
||||
<div className="mt-2 border rounded-lg divide-y max-h-48 overflow-y-auto">
|
||||
{searchResults.map((l: any) => (
|
||||
<div key={l.id} className="flex items-center justify-between px-3 py-2 hover:bg-muted/50">
|
||||
<div>
|
||||
<p className="text-sm">{l.companyName || l.contactName}</p>
|
||||
<p className="text-xs text-muted-foreground">{l.email}</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 text-xs"
|
||||
disabled={subscribing || campaign.subscribers?.some((s: any) => s.lead_id === l.id)}
|
||||
onClick={() => subscribeLeads([l.id])}
|
||||
>
|
||||
{campaign.subscribers?.some((s: any) => s.lead_id === l.id) ? "Added" : "Add"}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{campaign.subscribers?.length === 0 ? (
|
||||
<Card className="p-8 text-center text-muted-foreground">
|
||||
<p>No subscribers yet. Search and add leads above.</p>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="divide-y rounded-lg border">
|
||||
{campaign.subscribers.map((s: any) => (
|
||||
<div key={s.id} className="flex items-center justify-between px-4 py-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium">{s.contact_name || s.lead_email || s.email}</p>
|
||||
<p className="text-xs text-muted-foreground">{s.email} · Step {s.current_step + 1}</p>
|
||||
</div>
|
||||
<Badge variant="outline" className="text-[10px]">{s.status}</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="send" className="space-y-4">
|
||||
<Card className="p-6 text-center space-y-4">
|
||||
<Mail className="h-10 w-10 text-muted-foreground mx-auto" />
|
||||
<div>
|
||||
<p className="font-medium">Send Next Emails</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{campaign.subscribers?.filter((s: any) => s.status === "pending").length || 0} subscribers ready for the next step
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={sendCampaign} disabled={sending || campaign.steps?.length === 0}>
|
||||
<Send className="mr-1.5 h-4 w-4" />
|
||||
{sending ? "Sending..." : "Send Next Batch"}
|
||||
</Button>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Layers({ className }: { className?: string }) {
|
||||
return <svg className={className} xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="m12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.57 3.91a2 2 0 0 0 1.66 0l8.57-3.91a1 1 0 0 0 0-1.83Z"/><path d="m22 17.65-9.17 4.16a2 2 0 0 1-1.66 0L2 17.65"/><path d="m22 12.65-9.17 4.16a2 2 0 0 1-1.66 0L2 12.65"/></svg>
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { PageHeader } from "@/components/shared/page-header"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { ArrowLeft } from "lucide-react"
|
||||
|
||||
export default function NewCampaignPage() {
|
||||
const router = useRouter()
|
||||
const [name, setName] = useState("")
|
||||
const [description, setDescription] = useState("")
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!name.trim()) return
|
||||
setSaving(true)
|
||||
try {
|
||||
const res = await fetch("/api/campaigns", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: name.trim(), description: description.trim() || null }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (data.id) router.push(`/campaigns/${data.id}`)
|
||||
} catch { /* ignore */ }
|
||||
setSaving(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-2xl mx-auto">
|
||||
<PageHeader title="New Campaign" description="Create an automated email sequence">
|
||||
<Button variant="outline" size="sm" onClick={() => router.push("/campaigns")}>
|
||||
<ArrowLeft className="mr-1.5 h-4 w-4" /> Back
|
||||
</Button>
|
||||
</PageHeader>
|
||||
<Card className="p-6 space-y-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium">Campaign Name *</label>
|
||||
<Input value={name} onChange={e => setName(e.target.value)} placeholder="e.g. New Lead Follow-up" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium">Description</label>
|
||||
<textarea value={description} onChange={e => setDescription(e.target.value)} className="flex min-h-[80px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm" placeholder="What is this campaign for?" />
|
||||
</div>
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button variant="outline" onClick={() => router.push("/campaigns")}>Cancel</Button>
|
||||
<Button onClick={handleSubmit} disabled={saving || !name.trim()}>{saving ? "Creating..." : "Create Campaign"}</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { PageHeader } from "@/components/shared/page-header"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Plus, Mail, Users, Layers, Calendar } from "lucide-react"
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
draft: "bg-zinc-100 text-zinc-700 border-zinc-300",
|
||||
active: "bg-emerald-100 text-emerald-700 border-emerald-300",
|
||||
paused: "bg-amber-100 text-amber-700 border-amber-300",
|
||||
completed: "bg-blue-100 text-blue-700 border-blue-300",
|
||||
}
|
||||
|
||||
export default function CampaignsPage() {
|
||||
const router = useRouter()
|
||||
const [campaigns, setCampaigns] = useState<any[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/campaigns")
|
||||
.then(r => r.json())
|
||||
.then(d => { if (Array.isArray(d)) setCampaigns(d); else setCampaigns([]) })
|
||||
.catch(() => setCampaigns([]))
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<PageHeader title="Email Campaigns" description="Automated drip sequences for lead follow-ups">
|
||||
<Button size="sm" onClick={() => router.push("/campaigns/new")}>
|
||||
<Plus className="mr-1.5 h-4 w-4" /> New Campaign
|
||||
</Button>
|
||||
</PageHeader>
|
||||
|
||||
{loading ? (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{[1,2,3].map(i => <Card key={i} className="h-32 animate-pulse p-4"><div className="h-4 w-32 rounded bg-muted" /></Card>)}
|
||||
</div>
|
||||
) : campaigns.length === 0 ? (
|
||||
<Card className="p-12 text-center">
|
||||
<Mail className="h-10 w-10 text-muted-foreground mx-auto mb-3" />
|
||||
<p className="text-muted-foreground">No campaigns yet</p>
|
||||
<Button variant="outline" size="sm" className="mt-4" onClick={() => router.push("/campaigns/new")}>
|
||||
Create your first campaign
|
||||
</Button>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{campaigns.map((c) => (
|
||||
<Card key={c.id} className="p-4 cursor-pointer hover:border-primary/50 transition-colors" onClick={() => router.push(`/campaigns/${c.id}`)}>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium truncate">{c.name}</p>
|
||||
{c.description && <p className="text-xs text-muted-foreground truncate mt-0.5">{c.description}</p>}
|
||||
</div>
|
||||
<Badge variant="outline" className={`text-[10px] ml-2 ${statusColors[c.status] || ""}`}>{c.status}</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1"><Users className="h-3 w-3" />{c.subscriber_count || 0}</span>
|
||||
<span className="flex items-center gap-1"><Layers className="h-3 w-3" />{c.step_count || 0} steps</span>
|
||||
<span className="flex items-center gap-1"><Calendar className="h-3 w-3" />{new Date(c.created_at).toLocaleDateString()}</span>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -7,7 +7,8 @@ import { UserPreferencesForm } from "@/components/settings/user-preferences-form
|
||||
import { ThemeSettings } from "@/components/settings/theme-settings"
|
||||
import { NotificationSettings } from "@/components/settings/notification-settings"
|
||||
import { CustomFieldsManager } from "@/components/settings/custom-fields-manager"
|
||||
import { Building2, User, Palette, Bell, ListChecks } from "lucide-react"
|
||||
import { LeadScoringSettings } from "@/components/settings/lead-scoring-settings"
|
||||
import { Building2, User, Palette, Bell, ListChecks, Gauge } from "lucide-react"
|
||||
|
||||
export default function SettingsPage() {
|
||||
const tabs = [
|
||||
@@ -16,6 +17,7 @@ export default function SettingsPage() {
|
||||
{ value: "theme", label: "Theme", icon: Palette, component: ThemeSettings },
|
||||
{ value: "notifications", label: "Notifications", icon: Bell, component: NotificationSettings },
|
||||
{ value: "custom-fields", label: "Custom Fields", icon: ListChecks, component: CustomFieldsManager },
|
||||
{ value: "lead-scoring", label: "Lead Scoring", icon: Gauge, component: LeadScoringSettings },
|
||||
]
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { getSessionUser } from "@/lib/auth"
|
||||
import { query } from "@/lib/db"
|
||||
|
||||
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const user = await getSessionUser()
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
const { id } = await params
|
||||
const result = await query(
|
||||
`SELECT l.*, s.contact_name, s.email AS subscriber_email
|
||||
FROM email_campaign_logs l
|
||||
LEFT JOIN email_campaign_subscribers s ON s.id = l.subscriber_id
|
||||
WHERE l.campaign_id = $1 ORDER BY l.sent_at DESC LIMIT 100`,
|
||||
[id]
|
||||
)
|
||||
return NextResponse.json(result.rows)
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: "Failed" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { getSessionUser } from "@/lib/auth"
|
||||
import { query } from "@/lib/db"
|
||||
|
||||
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const user = await getSessionUser()
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
const { id } = await params
|
||||
|
||||
const campaign = await query(
|
||||
`SELECT c.*, u.first_name || ' ' || u.last_name AS creator_name
|
||||
FROM email_campaigns c LEFT JOIN users u ON u.id = c.created_by
|
||||
WHERE c.id = $1 AND c.deleted_at IS NULL`,
|
||||
[id]
|
||||
)
|
||||
if (campaign.rows.length === 0) return NextResponse.json({ error: "Not found" }, { status: 404 })
|
||||
|
||||
const steps = await query(
|
||||
"SELECT * FROM email_campaign_steps WHERE campaign_id = $1 ORDER BY step_order",
|
||||
[id]
|
||||
)
|
||||
|
||||
const subscribers = await query(
|
||||
`SELECT s.*, l.company_name, l.contact_name, l.email AS lead_email
|
||||
FROM email_campaign_subscribers s
|
||||
LEFT JOIN leads l ON l.id = s.lead_id
|
||||
WHERE s.campaign_id = $1 ORDER BY s.subscribed_at DESC`,
|
||||
[id]
|
||||
)
|
||||
|
||||
return NextResponse.json({ ...campaign.rows[0], steps: steps.rows, subscribers: subscribers.rows })
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: "Failed" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const user = await getSessionUser()
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
const { id } = await params
|
||||
const body = await request.json()
|
||||
|
||||
const fields: string[] = []
|
||||
const values: any[] = []
|
||||
let idx = 1
|
||||
if (body.name !== undefined) { fields.push(`name = $${idx++}`); values.push(body.name) }
|
||||
if (body.description !== undefined) { fields.push(`description = $${idx++}`); values.push(body.description) }
|
||||
if (body.status !== undefined) { fields.push(`status = $${idx++}`); values.push(body.status) }
|
||||
if (fields.length === 0) return NextResponse.json({ error: "No fields" }, { status: 400 })
|
||||
fields.push("updated_at = NOW()")
|
||||
values.push(id)
|
||||
|
||||
await query(`UPDATE email_campaigns SET ${fields.join(", ")} WHERE id = $${idx}`, values)
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: "Failed" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const user = await getSessionUser()
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
const { id } = await params
|
||||
await query("UPDATE email_campaigns SET deleted_at = NOW() WHERE id = $1", [id])
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: "Failed" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { getSessionUser } from "@/lib/auth"
|
||||
import { query } from "@/lib/db"
|
||||
|
||||
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const user = await getSessionUser()
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
const { id } = await params
|
||||
|
||||
const steps = await query(
|
||||
"SELECT * FROM email_campaign_steps WHERE campaign_id = $1 ORDER BY step_order",
|
||||
[id]
|
||||
)
|
||||
if (steps.rows.length === 0) {
|
||||
return NextResponse.json({ error: "No steps defined" }, { status: 400 })
|
||||
}
|
||||
|
||||
const subscribers = await query(
|
||||
"SELECT * FROM email_campaign_subscribers WHERE campaign_id = $1 AND status = 'pending' AND unsubscribed_at IS NULL",
|
||||
[id]
|
||||
)
|
||||
|
||||
let sent = 0
|
||||
for (const sub of subscribers.rows) {
|
||||
const stepIdx = sub.current_step
|
||||
if (stepIdx >= steps.rows.length) continue
|
||||
const step = steps.rows[stepIdx]
|
||||
|
||||
const body = replaceVariables(step.body_text || step.subject, { name: sub.contact_name || "Valued Client", email: sub.email })
|
||||
|
||||
await query(
|
||||
`INSERT INTO email_campaign_logs (campaign_id, subscriber_id, step_id, recipient, subject, body_text, status)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, 'sent')`,
|
||||
[id, sub.id, step.id, sub.email, step.subject, body]
|
||||
)
|
||||
|
||||
await query(
|
||||
`UPDATE email_campaign_subscribers SET current_step = current_step + 1, last_sent_at = NOW(), status = CASE WHEN $1 + 1 >= $2 THEN 'completed' ELSE 'pending' END
|
||||
WHERE id = $3`,
|
||||
[stepIdx, steps.rows.length, sub.id]
|
||||
)
|
||||
sent++
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, sent })
|
||||
} catch (error) {
|
||||
console.error("Campaign send error:", error)
|
||||
return NextResponse.json({ error: "Failed to send" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
function replaceVariables(text: string, vars: Record<string, string>): string {
|
||||
return text.replace(/\{\{(\w+)\}\}/g, (_, key) => vars[key] || `{{${key}}}`)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { getSessionUser } from "@/lib/auth"
|
||||
import { query } from "@/lib/db"
|
||||
|
||||
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const user = await getSessionUser()
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
const { id } = await params
|
||||
const result = await query("SELECT * FROM email_campaign_steps WHERE campaign_id = $1 ORDER BY step_order", [id])
|
||||
return NextResponse.json(result.rows)
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: "Failed" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const user = await getSessionUser()
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
const { id } = await params
|
||||
const body = await request.json()
|
||||
|
||||
const maxOrder = await query("SELECT COALESCE(MAX(step_order), -1) + 1 AS next FROM email_campaign_steps WHERE campaign_id = $1", [id])
|
||||
const nextOrder = maxOrder.rows[0]?.next || 0
|
||||
|
||||
const result = await query(
|
||||
`INSERT INTO email_campaign_steps (campaign_id, step_order, delay_days, subject, body_text, body_html, variables)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id`,
|
||||
[id, nextOrder, body.delayDays || 0, body.subject, body.bodyText || null, body.bodyHtml || null, body.variables ? JSON.stringify(body.variables) : null]
|
||||
)
|
||||
return NextResponse.json({ success: true, id: result.rows[0].id }, { status: 201 })
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: "Failed" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { getSessionUser } from "@/lib/auth"
|
||||
import { query } from "@/lib/db"
|
||||
|
||||
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const user = await getSessionUser()
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
const { id } = await params
|
||||
const body = await request.json()
|
||||
|
||||
if (body.leadIds && Array.isArray(body.leadIds)) {
|
||||
let added = 0
|
||||
for (const leadId of body.leadIds) {
|
||||
const lead = await query("SELECT id, email, contact_name FROM leads WHERE id = $1 AND deleted_at IS NULL", [leadId])
|
||||
if (lead.rows.length === 0) continue
|
||||
const l = lead.rows[0]
|
||||
if (!l.email) continue
|
||||
try {
|
||||
await query(
|
||||
`INSERT INTO email_campaign_subscribers (campaign_id, lead_id, email, contact_name)
|
||||
VALUES ($1, $2, $3, $4) ON CONFLICT DO NOTHING`,
|
||||
[id, l.id, l.email, l.contact_name]
|
||||
)
|
||||
added++
|
||||
} catch { /* skip duplicates */ }
|
||||
}
|
||||
return NextResponse.json({ success: true, added })
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "leadIds array required" }, { status: 400 })
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: "Failed" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { getSessionUser } from "@/lib/auth"
|
||||
import { query } from "@/lib/db"
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const user = await getSessionUser()
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
const result = await query(
|
||||
`SELECT c.*, u.first_name || ' ' || u.last_name AS creator_name,
|
||||
(SELECT COUNT(*) FROM email_campaign_subscribers WHERE campaign_id = c.id) AS subscriber_count,
|
||||
(SELECT COUNT(*) FROM email_campaign_steps WHERE campaign_id = c.id) AS step_count
|
||||
FROM email_campaigns c
|
||||
LEFT JOIN users u ON u.id = c.created_by
|
||||
WHERE c.deleted_at IS NULL
|
||||
ORDER BY c.created_at DESC`
|
||||
)
|
||||
return NextResponse.json(result.rows)
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: "Failed to load campaigns" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const user = await getSessionUser()
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
const body = await request.json()
|
||||
const result = await query(
|
||||
`INSERT INTO email_campaigns (name, description, status, trigger_type, trigger_config, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6) RETURNING id`,
|
||||
[body.name, body.description || null, body.status || "draft", body.triggerType || "manual", body.triggerConfig ? JSON.stringify(body.triggerConfig) : null, user.id]
|
||||
)
|
||||
return NextResponse.json({ success: true, id: result.rows[0].id }, { status: 201 })
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: "Failed to create campaign" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
const result = await query(
|
||||
`SELECT l.id, l.company_name, l.contact_name, l.email, l.phone, l.score,
|
||||
l.assigned_to, l.created_at, l.updated_at, l.notes, l.source_id,
|
||||
l.custom_fields,
|
||||
l.custom_fields, l.ml_score,
|
||||
ls.name AS stage_name,
|
||||
u.id AS user_id, u.first_name, u.last_name, u.email AS user_email, u.avatar_url
|
||||
FROM leads l
|
||||
@@ -61,6 +61,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
avatar: avatarSvgUrl(`${r.first_name} ${r.last_name}`),
|
||||
} : null,
|
||||
customFields: r.custom_fields || {},
|
||||
mlScore: r.ml_score || 0,
|
||||
createdAt: r.created_at,
|
||||
updatedAt: r.updated_at,
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ export async function GET(request: NextRequest) {
|
||||
const offset = parseInt(searchParams.get("offset") || "0", 10)
|
||||
const isAdmin = user.role === "admin" || user.role === "super_admin"
|
||||
|
||||
let sql = `SELECT l.id, l.company_name, l.contact_name, l.email, l.phone, l.score,
|
||||
let sql = `SELECT l.id, l.company_name, l.contact_name, l.email, l.phone, l.score, l.ml_score,
|
||||
l.assigned_to, l.created_at, l.updated_at, l.notes, l.source_id,
|
||||
ls.name AS stage_name,
|
||||
u.id AS user_id, u.first_name, u.last_name, u.email AS user_email, u.avatar_url
|
||||
@@ -101,6 +101,7 @@ export async function GET(request: NextRequest) {
|
||||
email: r.user_email,
|
||||
avatar: avatarSvgUrl(`${r.first_name} ${r.last_name}`),
|
||||
} : null,
|
||||
mlScore: r.ml_score || 0,
|
||||
createdAt: r.created_at,
|
||||
updatedAt: r.updated_at,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { getSessionUser } from "@/lib/auth"
|
||||
import { query } from "@/lib/db"
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const user = await getSessionUser()
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
const result = await query("SELECT * FROM lead_scoring_config WHERE is_active = true ORDER BY weight DESC")
|
||||
return NextResponse.json(result.rows)
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: "Failed to load scoring config" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const user = await getSessionUser()
|
||||
if (!user || (user.role !== "admin" && user.role !== "super_admin")) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
|
||||
}
|
||||
const body = await request.json()
|
||||
const result = await query(
|
||||
`INSERT INTO lead_scoring_config (feature_name, feature_type, field_ref, weight, value_map)
|
||||
VALUES ($1, $2, $3, $4, $5) RETURNING id`,
|
||||
[body.featureName, body.featureType || "keyword", body.fieldRef, body.weight || 1, body.valueMap ? JSON.stringify(body.valueMap) : null]
|
||||
)
|
||||
return NextResponse.json({ success: true, id: result.rows[0].id }, { status: 201 })
|
||||
} catch (error: any) {
|
||||
if (error?.constraint === "lead_scoring_config_feature_name_key") {
|
||||
return NextResponse.json({ error: "Feature name already exists" }, { status: 409 })
|
||||
}
|
||||
return NextResponse.json({ error: "Failed to create feature" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(request: NextRequest) {
|
||||
try {
|
||||
const user = await getSessionUser()
|
||||
if (!user || (user.role !== "admin" && user.role !== "super_admin")) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
|
||||
}
|
||||
const body = await request.json()
|
||||
if (!body.id) return NextResponse.json({ error: "id required" }, { status: 400 })
|
||||
|
||||
const fields: string[] = []
|
||||
const values: any[] = []
|
||||
let idx = 1
|
||||
if (body.weight !== undefined) { fields.push(`weight = $${idx++}`); values.push(body.weight) }
|
||||
if (body.isActive !== undefined) { fields.push(`is_active = $${idx++}`); values.push(body.isActive) }
|
||||
if (body.valueMap !== undefined) { fields.push(`value_map = $${idx++}`); values.push(JSON.stringify(body.valueMap)) }
|
||||
if (fields.length === 0) return NextResponse.json({ error: "No fields" }, { status: 400 })
|
||||
fields.push("updated_at = NOW()")
|
||||
values.push(body.id)
|
||||
|
||||
await query(`UPDATE lead_scoring_config SET ${fields.join(", ")} WHERE id = $${idx}`, values)
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: "Failed to update" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
const user = await getSessionUser()
|
||||
if (!user || (user.role !== "admin" && user.role !== "super_admin")) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
|
||||
}
|
||||
const { searchParams } = new URL(request.url)
|
||||
const id = searchParams.get("id")
|
||||
if (!id) return NextResponse.json({ error: "id required" }, { status: 400 })
|
||||
await query("DELETE FROM lead_scoring_config WHERE id = $1", [id])
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: "Failed to delete" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { getSessionUser } from "@/lib/auth"
|
||||
import { query } from "@/lib/db"
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const user = await getSessionUser()
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const leadId = searchParams.get("leadId")
|
||||
|
||||
const configResult = await query("SELECT * FROM lead_scoring_config WHERE is_active = true ORDER BY weight DESC")
|
||||
const config = configResult.rows
|
||||
|
||||
let leads: any[]
|
||||
if (leadId) {
|
||||
const r = await query("SELECT id, company_name, contact_name, email, phone, source, notes, custom_fields FROM leads WHERE id = $1 AND deleted_at IS NULL", [leadId])
|
||||
leads = r.rows
|
||||
} else {
|
||||
const r = await query("SELECT id, company_name, contact_name, email, phone, source, notes, custom_fields FROM leads WHERE deleted_at IS NULL AND ml_score IS NULL")
|
||||
leads = r.rows
|
||||
}
|
||||
|
||||
let scored = 0
|
||||
for (const lead of leads) {
|
||||
let score = 0
|
||||
const details: Record<string, number> = {}
|
||||
|
||||
for (const f of config) {
|
||||
let value: any = (lead as any)[f.field_ref]
|
||||
if (value === undefined || value === null) value = ""
|
||||
|
||||
let points = 0
|
||||
switch (f.feature_type) {
|
||||
case "boolean": {
|
||||
const hasVal = typeof value === "string" ? value.trim().length > 0 : value !== null && value !== undefined
|
||||
if (f.value_map) {
|
||||
const map = f.value_map as Record<string, any>
|
||||
points = hasVal ? Number(map["true"] || 0) : Number(map["false"] || 0)
|
||||
} else {
|
||||
points = hasVal ? f.weight : 0
|
||||
}
|
||||
break
|
||||
}
|
||||
case "keyword": {
|
||||
if (f.value_map) {
|
||||
const map = f.value_map as Record<string, any>
|
||||
const strVal = String(value).toLowerCase()
|
||||
for (const [key, val] of Object.entries(map)) {
|
||||
if (strVal.includes(key.toLowerCase())) {
|
||||
points = Number(val)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
case "range": {
|
||||
if (f.value_map) {
|
||||
const map = f.value_map as Record<string, any>
|
||||
const numVal = typeof value === "string" ? value.length : Number(value) || 0
|
||||
let bestMatch = 0
|
||||
for (const [threshold, val] of Object.entries(map)) {
|
||||
if (numVal >= Number(threshold)) bestMatch = Number(val)
|
||||
}
|
||||
points = bestMatch
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
details[f.feature_name] = points
|
||||
score += points
|
||||
}
|
||||
|
||||
score = Math.min(Math.max(score, 0), 100)
|
||||
|
||||
await query("UPDATE leads SET ml_score = $1, ml_score_details = $2 WHERE id = $3", [score, JSON.stringify(details), lead.id])
|
||||
scored++
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, scored })
|
||||
} catch (error) {
|
||||
console.error("Lead scoring error:", error)
|
||||
return NextResponse.json({ error: "Scoring failed" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
FileText,
|
||||
Clock,
|
||||
BarChart3,
|
||||
Mail,
|
||||
} from "lucide-react"
|
||||
|
||||
import { useUser } from "@/providers/user-provider"
|
||||
@@ -40,6 +41,7 @@ const navItems = [
|
||||
{ href: "/proposals", label: "Proposals", icon: FileText },
|
||||
{ href: "/time-tracking", label: "Time", icon: Clock },
|
||||
{ href: "/resource-planning", label: "Resources", icon: BarChart3 },
|
||||
{ href: "/campaigns", label: "Campaigns", icon: Mail },
|
||||
{ href: "/ai-assistant", label: "AI Assistant", icon: Bot, roles: ["sales", "admin", "super_admin"] },
|
||||
{ href: "/users", label: "Users", icon: Building2 },
|
||||
{ href: "/settings", label: "Settings", icon: Settings },
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Plus, Trash2, RefreshCw, Gauge } from "lucide-react"
|
||||
|
||||
interface ScoringFeature {
|
||||
id: string
|
||||
feature_name: string
|
||||
feature_type: string
|
||||
field_ref: string
|
||||
weight: number
|
||||
value_map: any
|
||||
is_active: boolean
|
||||
}
|
||||
|
||||
export function LeadScoringSettings() {
|
||||
const [features, setFeatures] = useState<ScoringFeature[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [scoring, setScoring] = useState(false)
|
||||
const [newFeature, setNewFeature] = useState({ featureName: "", fieldRef: "", featureType: "keyword", weight: 5 })
|
||||
|
||||
const loadFeatures = async () => {
|
||||
const res = await fetch("/api/leads/score/config")
|
||||
if (res.ok) setFeatures(await res.json())
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
useEffect(() => { loadFeatures() }, [])
|
||||
|
||||
const addFeature = async () => {
|
||||
if (!newFeature.featureName) return
|
||||
const res = await fetch("/api/leads/score/config", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(newFeature),
|
||||
})
|
||||
if (res.ok) { setNewFeature({ featureName: "", fieldRef: "", featureType: "keyword", weight: 5 }); loadFeatures() }
|
||||
}
|
||||
|
||||
const deleteFeature = async (id: string) => {
|
||||
await fetch(`/api/leads/score/config?id=${id}`, { method: "DELETE" })
|
||||
loadFeatures()
|
||||
}
|
||||
|
||||
const toggleFeature = async (f: ScoringFeature) => {
|
||||
await fetch("/api/leads/score/config", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ id: f.id, isActive: !f.is_active }),
|
||||
})
|
||||
loadFeatures()
|
||||
}
|
||||
|
||||
const runScoring = async () => {
|
||||
setScoring(true)
|
||||
try {
|
||||
const res = await fetch("/api/leads/score", { method: "POST" })
|
||||
const data = await res.json()
|
||||
alert(`Scored ${data.scored} leads`)
|
||||
} catch { alert("Scoring failed") }
|
||||
setScoring(false)
|
||||
}
|
||||
|
||||
const typeLabels: Record<string, string> = { boolean: "Boolean", keyword: "Keyword Match", range: "Range" }
|
||||
const typeColors: Record<string, string> = { boolean: "bg-blue-100 text-blue-700", keyword: "bg-emerald-100 text-emerald-700", range: "bg-amber-100 text-amber-700" }
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">Configure how leads are scored based on their attributes. Scores range from 0-100.</p>
|
||||
<Button size="sm" variant="outline" onClick={runScoring} disabled={scoring}>
|
||||
<RefreshCw className={`mr-1.5 h-4 w-4 ${scoring ? "animate-spin" : ""}`} />
|
||||
{scoring ? "Scoring..." : "Score All Leads"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader><CardTitle className="text-base">Scoring Features</CardTitle></CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{loading ? (
|
||||
<p className="text-sm text-muted-foreground">Loading...</p>
|
||||
) : features.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No scoring features defined.</p>
|
||||
) : (
|
||||
features.map((f) => (
|
||||
<div key={f.id} className="flex items-center gap-3 rounded-lg border p-3">
|
||||
<Gauge className="h-5 w-5 text-muted-foreground shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">{f.feature_name}</span>
|
||||
<Badge variant="outline" className={`text-[10px] ${typeColors[f.feature_type] || ""}`}>
|
||||
{typeLabels[f.feature_type] || f.feature_type}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">field: {f.field_ref} · weight: {f.weight}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant={f.is_active ? "default" : "outline"} className="h-7 text-xs" onClick={() => toggleFeature(f)}>
|
||||
{f.is_active ? "Active" : "Disabled"}
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => deleteFeature(f.id)}>
|
||||
<Trash2 className="h-3.5 w-3.5 text-red-500" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader><CardTitle className="text-base">Add Feature</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-3 md:grid-cols-4">
|
||||
<div>
|
||||
<label className="text-xs font-medium">Feature Name</label>
|
||||
<Input value={newFeature.featureName} onChange={e => setNewFeature(p => ({ ...p, featureName: e.target.value }))} placeholder="e.g. Has Website" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium">Field Reference</label>
|
||||
<Input value={newFeature.fieldRef} onChange={e => setNewFeature(p => ({ ...p, fieldRef: e.target.value }))} placeholder="e.g. source" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium">Type</label>
|
||||
<select value={newFeature.featureType} onChange={e => setNewFeature(p => ({ ...p, featureType: e.target.value }))} className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm">
|
||||
<option value="keyword">Keyword Match</option>
|
||||
<option value="boolean">Boolean (has value)</option>
|
||||
<option value="range">Range</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium">Weight</label>
|
||||
<div className="flex gap-2">
|
||||
<Input type="number" min="1" max="100" value={newFeature.weight} onChange={e => setNewFeature(p => ({ ...p, weight: parseInt(e.target.value) || 1 }))} />
|
||||
<Button size="sm" className="shrink-0" onClick={addFeature}><Plus className="h-4 w-4" /></Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -29,6 +29,7 @@ export interface Lead {
|
||||
assignedUserId: string | null;
|
||||
assignedUser: User | null;
|
||||
customFields?: Record<string, any>;
|
||||
mlScore?: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user