Add Lead Scoring ML + Email Sequences/Drip Campaigns
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:
2026-07-01 13:33:24 +02:00
parent 963ea1d1b8
commit 00bda54695
20 changed files with 1068 additions and 3 deletions
+35
View File
@@ -0,0 +1,35 @@
-- ============================================================================
-- Lead Scoring ML — Configurable scoring engine with historical training
-- ============================================================================
CREATE TABLE IF NOT EXISTS lead_scoring_config (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
feature_name VARCHAR(100) NOT NULL UNIQUE,
feature_type VARCHAR(50) NOT NULL DEFAULT 'keyword',
field_ref VARCHAR(100) NOT NULL,
weight DECIMAL(5,2) NOT NULL DEFAULT 1.00,
value_map JSONB,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_lead_scoring_active ON lead_scoring_config(is_active);
ALTER TABLE leads ADD COLUMN IF NOT EXISTS ml_score INT DEFAULT 0;
ALTER TABLE leads ADD COLUMN IF NOT EXISTS ml_score_details JSONB;
-- Seed default scoring features
INSERT INTO lead_scoring_config (feature_name, feature_type, field_ref, weight, value_map) VALUES
('Has Email', 'boolean', 'email', 5, '{"true": 5, "false": 0}'),
('Has Phone', 'boolean', 'phone', 5, '{"true": 5, "false": 0}'),
('Source: Referral', 'keyword', 'source', 15, '{"referral": 15, "friend": 15, "word_of_mouth": 15}'),
('Source: Website', 'keyword', 'source', 10, '{"website": 10, "organic": 8, "direct": 8}'),
('Source: Social', 'keyword', 'source', 8, '{"facebook": 8, "linkedin": 8, "twitter": 5, "instagram": 5}'),
('Source: Paid', 'keyword', 'source', 6, '{"google_ads": 6, "paid": 6, "ppc": 6}'),
('Company Name Present', 'boolean', 'company_name', 10, '{"true": 10, "false": 0}'),
('Description Length', 'range', 'description', 10, '{"0": 0, "50": 5, "200": 10, "500": 15}')
ON CONFLICT DO NOTHING;
-- Update ml_score for all existing leads
UPDATE leads SET ml_score = 0 WHERE ml_score IS NULL;
@@ -0,0 +1,62 @@
-- ============================================================================
-- Email Sequences / Drip Campaigns — Automated follow-up sequences
-- ============================================================================
CREATE TABLE IF NOT EXISTS email_campaigns (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(255) NOT NULL,
description TEXT,
status VARCHAR(50) NOT NULL DEFAULT 'draft',
trigger_type VARCHAR(50) NOT NULL DEFAULT 'manual',
trigger_config JSONB,
created_by UUID REFERENCES users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ
);
CREATE TABLE IF NOT EXISTS email_campaign_steps (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
campaign_id UUID NOT NULL REFERENCES email_campaigns(id) ON DELETE CASCADE,
step_order INT NOT NULL DEFAULT 0,
delay_days INT NOT NULL DEFAULT 0,
subject VARCHAR(500) NOT NULL,
body_text TEXT,
body_html TEXT,
variables JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS email_campaign_subscribers (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
campaign_id UUID NOT NULL REFERENCES email_campaigns(id) ON DELETE CASCADE,
lead_id UUID REFERENCES leads(id) ON DELETE SET NULL,
email VARCHAR(255) NOT NULL,
contact_name VARCHAR(255),
subscribed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
unsubscribed_at TIMESTAMPTZ,
status VARCHAR(50) NOT NULL DEFAULT 'pending',
current_step INT NOT NULL DEFAULT 0,
last_sent_at TIMESTAMPTZ,
CONSTRAINT uq_campaign_lead UNIQUE (campaign_id, lead_id)
);
CREATE TABLE IF NOT EXISTS email_campaign_logs (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
campaign_id UUID REFERENCES email_campaigns(id) ON DELETE CASCADE,
subscriber_id UUID REFERENCES email_campaign_subscribers(id) ON DELETE CASCADE,
step_id UUID REFERENCES email_campaign_steps(id) ON DELETE SET NULL,
recipient VARCHAR(255) NOT NULL,
subject VARCHAR(500),
body_text TEXT,
sent_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
status VARCHAR(50) NOT NULL DEFAULT 'sent',
error_message TEXT
);
ALTER TABLE email_campaign_subscribers ADD CONSTRAINT IF NOT EXISTS uq_campaign_lead UNIQUE (campaign_id, lead_id);
CREATE INDEX idx_campaign_steps_order ON email_campaign_steps(campaign_id, step_order);
CREATE INDEX idx_campaign_subscribers ON email_campaign_subscribers(campaign_id, status);
CREATE INDEX idx_campaign_logs ON email_campaign_logs(campaign_id, sent_at);
+6
View File
@@ -91,5 +91,11 @@ BEGIN;
\echo '=== Running 024_resource_planning.sql (Resource Planning) ===' \echo '=== Running 024_resource_planning.sql (Resource Planning) ==='
\i 024_resource_planning.sql \i 024_resource_planning.sql
\echo '=== Running 025_lead_scoring.sql (Lead Scoring ML) ==='
\i 025_lead_scoring.sql
\echo '=== Running 026_email_campaigns.sql (Email Campaigns) ==='
\i 026_email_campaigns.sql
\echo '=== Migration Complete ===' \echo '=== Migration Complete ==='
COMMIT; COMMIT;
+258
View File
@@ -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 &#123;&#123;name&#125;&#125; 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}},&#10;&#10;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>
)
}
+73
View File
@@ -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>
)
}
+3 -1
View File
@@ -7,7 +7,8 @@ import { UserPreferencesForm } from "@/components/settings/user-preferences-form
import { ThemeSettings } from "@/components/settings/theme-settings" import { ThemeSettings } from "@/components/settings/theme-settings"
import { NotificationSettings } from "@/components/settings/notification-settings" import { NotificationSettings } from "@/components/settings/notification-settings"
import { CustomFieldsManager } from "@/components/settings/custom-fields-manager" 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() { export default function SettingsPage() {
const tabs = [ const tabs = [
@@ -16,6 +17,7 @@ export default function SettingsPage() {
{ value: "theme", label: "Theme", icon: Palette, component: ThemeSettings }, { value: "theme", label: "Theme", icon: Palette, component: ThemeSettings },
{ value: "notifications", label: "Notifications", icon: Bell, component: NotificationSettings }, { value: "notifications", label: "Notifications", icon: Bell, component: NotificationSettings },
{ value: "custom-fields", label: "Custom Fields", icon: ListChecks, component: CustomFieldsManager }, { value: "custom-fields", label: "Custom Fields", icon: ListChecks, component: CustomFieldsManager },
{ value: "lead-scoring", label: "Lead Scoring", icon: Gauge, component: LeadScoringSettings },
] ]
return ( return (
+21
View File
@@ -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 })
}
}
+72
View File
@@ -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 })
}
}
+55
View File
@@ -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}}}`)
}
+36
View File
@@ -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 })
}
}
+38
View File
@@ -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 })
}
}
+2 -1
View File
@@ -28,7 +28,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
const result = await query( const result = await query(
`SELECT l.id, l.company_name, l.contact_name, l.email, l.phone, l.score, `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.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, ls.name AS stage_name,
u.id AS user_id, u.first_name, u.last_name, u.email AS user_email, u.avatar_url u.id AS user_id, u.first_name, u.last_name, u.email AS user_email, u.avatar_url
FROM leads l FROM leads l
@@ -61,6 +61,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
avatar: avatarSvgUrl(`${r.first_name} ${r.last_name}`), avatar: avatarSvgUrl(`${r.first_name} ${r.last_name}`),
} : null, } : null,
customFields: r.custom_fields || {}, customFields: r.custom_fields || {},
mlScore: r.ml_score || 0,
createdAt: r.created_at, createdAt: r.created_at,
updatedAt: r.updated_at, updatedAt: r.updated_at,
} }
+2 -1
View File
@@ -43,7 +43,7 @@ export async function GET(request: NextRequest) {
const offset = parseInt(searchParams.get("offset") || "0", 10) const offset = parseInt(searchParams.get("offset") || "0", 10)
const isAdmin = user.role === "admin" || user.role === "super_admin" 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, l.assigned_to, l.created_at, l.updated_at, l.notes, l.source_id,
ls.name AS stage_name, ls.name AS stage_name,
u.id AS user_id, u.first_name, u.last_name, u.email AS user_email, u.avatar_url 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, email: r.user_email,
avatar: avatarSvgUrl(`${r.first_name} ${r.last_name}`), avatar: avatarSvgUrl(`${r.first_name} ${r.last_name}`),
} : null, } : null,
mlScore: r.ml_score || 0,
createdAt: r.created_at, createdAt: r.created_at,
updatedAt: r.updated_at, updatedAt: r.updated_at,
} }
+77
View File
@@ -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 })
}
}
+88
View File
@@ -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 })
}
}
+2
View File
@@ -25,6 +25,7 @@ import {
FileText, FileText,
Clock, Clock,
BarChart3, BarChart3,
Mail,
} from "lucide-react" } from "lucide-react"
import { useUser } from "@/providers/user-provider" import { useUser } from "@/providers/user-provider"
@@ -40,6 +41,7 @@ const navItems = [
{ href: "/proposals", label: "Proposals", icon: FileText }, { href: "/proposals", label: "Proposals", icon: FileText },
{ href: "/time-tracking", label: "Time", icon: Clock }, { href: "/time-tracking", label: "Time", icon: Clock },
{ href: "/resource-planning", label: "Resources", icon: BarChart3 }, { 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: "/ai-assistant", label: "AI Assistant", icon: Bot, roles: ["sales", "admin", "super_admin"] },
{ href: "/users", label: "Users", icon: Building2 }, { href: "/users", label: "Users", icon: Building2 },
{ href: "/settings", label: "Settings", icon: Settings }, { 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>
)
}
+1
View File
@@ -29,6 +29,7 @@ export interface Lead {
assignedUserId: string | null; assignedUserId: string | null;
assignedUser: User | null; assignedUser: User | null;
customFields?: Record<string, any>; customFields?: Record<string, any>;
mlScore?: number;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
} }