Current state
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useCallback } from "react"
|
||||
import { AIChat } from "@/components/ai/ai-chat"
|
||||
import { JobSelector } from "@/components/ai/job-selector"
|
||||
import { Bot, Lightbulb, Target, MessageSquare } from "lucide-react"
|
||||
|
||||
export default function AIAssistantPage() {
|
||||
const [selectedJob, setSelectedJob] = useState<{ job_title: string; keywords: string[]; industry: string; description: string } | null>(null)
|
||||
|
||||
const handleJobSelect = useCallback((job: typeof selectedJob) => {
|
||||
setSelectedJob(job)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-3.5rem)]">
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
<div className="border-b border-[#2a2a35] px-6 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-9 w-9 rounded-lg bg-[#1BB0CE]/15 flex items-center justify-center">
|
||||
<Bot className="h-5 w-5 text-[#1BB0CE]" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold text-[#e8e8ef]">AI Sales Assistant</h1>
|
||||
<p className="text-xs text-[#6a6a75]">Uncensored sales tips and strategies powered by local AI</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex">
|
||||
<div className="flex-1 flex flex-col min-w-0 border-r border-[#2a2a35]">
|
||||
<AIChat />
|
||||
</div>
|
||||
|
||||
<div className="w-72 flex-none p-4 space-y-4 overflow-y-auto">
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold text-[#6a6a75] uppercase tracking-wider mb-2 flex items-center gap-1.5">
|
||||
<Target className="h-3.5 w-3.5" />
|
||||
Target Job
|
||||
</h3>
|
||||
<JobSelector onSelect={handleJobSelect} />
|
||||
</div>
|
||||
|
||||
{selectedJob && (
|
||||
<div className="bg-[#1a1a24] border border-[#2a2a35] rounded-lg p-3 space-y-2">
|
||||
<h4 className="text-sm font-medium text-[#e8e8ef]">{selectedJob.job_title}</h4>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-[#1BB0CE]/10 text-[#1BB0CE]">{selectedJob.industry}</span>
|
||||
</div>
|
||||
<p className="text-xs text-[#8a8a95]">{selectedJob.description}</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{selectedJob.keywords.map((kw, i) => (
|
||||
<span key={i} className="text-xs px-1.5 py-0.5 rounded bg-[#2a2a35] text-[#6a6a75]">
|
||||
{kw}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-[#1a1a24] border border-[#2a2a35] rounded-lg p-3 space-y-2">
|
||||
<h4 className="text-xs font-semibold text-[#6a6a75] uppercase tracking-wider flex items-center gap-1.5">
|
||||
<Lightbulb className="h-3.5 w-3.5" />
|
||||
Tips
|
||||
</h4>
|
||||
<ul className="space-y-1.5 text-xs text-[#8a8a95]">
|
||||
<li className="flex gap-2">
|
||||
<MessageSquare className="h-3 w-3 mt-0.5 flex-none text-[#1BB0CE]" />
|
||||
Ask for cold email templates for a specific job
|
||||
</li>
|
||||
<li className="flex gap-2">
|
||||
<MessageSquare className="h-3 w-3 mt-0.5 flex-none text-[#1BB0CE]" />
|
||||
Request objection handling tips
|
||||
</li>
|
||||
<li className="flex gap-2">
|
||||
<MessageSquare className="h-3 w-3 mt-0.5 flex-none text-[#1BB0CE]" />
|
||||
Ask for outreach strategies per industry
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,119 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect, useRef } from "react"
|
||||
import { PageHeader } from "@/components/shared/page-header"
|
||||
import { StatCard } from "@/components/dashboard/stat-card"
|
||||
import { StatCardSkeleton } from "@/components/dashboard/stat-card-skeleton"
|
||||
import { RecentLeadsTable } from "@/components/dashboard/recent-leads-table"
|
||||
import { LeadStatusChart } from "@/components/dashboard/lead-status-chart"
|
||||
import { LeadsPerMonthChart } from "@/components/dashboard/leads-per-month-chart"
|
||||
import {
|
||||
Users,
|
||||
UserPlus,
|
||||
PhoneCall,
|
||||
Clock,
|
||||
CheckCircle2,
|
||||
TrendingUp,
|
||||
ListFilter,
|
||||
} from "lucide-react"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { DashboardStats } from "@/types"
|
||||
import { CrossedLightsabers } from "@/components/dashboard/crossed-lightsabers"
|
||||
import { StarField } from "@/components/dashboard/star-field"
|
||||
|
||||
export default function DashboardPage() {
|
||||
const [period, setPeriod] = useState("6months")
|
||||
const [stats, setStats] = useState<DashboardStats | null>(null)
|
||||
const pollingRef = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
async function fetchStats(p: string) {
|
||||
try {
|
||||
const res = await fetch(`/api/dashboard?period=${p}`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setStats(data)
|
||||
}
|
||||
} catch {
|
||||
console.warn("Failed to fetch dashboard stats")
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchStats(period)
|
||||
pollingRef.current = setInterval(() => fetchStats(period), 30000)
|
||||
return () => {
|
||||
if (pollingRef.current) clearInterval(pollingRef.current)
|
||||
}
|
||||
}, [period])
|
||||
|
||||
const statCards = stats
|
||||
? [
|
||||
{ title: "Total Leads", value: stats.totalLeads, icon: Users, description: stats.periodLabel, trend: stats.trends.totalLeads, sparklineField: "total" as const },
|
||||
{ title: "Open Leads", value: stats.openLeads, icon: UserPlus, description: "Needs attention", trend: stats.trends.openLeads, sparklineField: "open" as const },
|
||||
{ title: "Contacted", value: stats.contactedLeads, icon: PhoneCall, description: "In conversation", trend: stats.trends.contactedLeads, sparklineField: "contacted" as const },
|
||||
{ title: "Pending", value: stats.pendingLeads, icon: Clock, description: "Awaiting decision", trend: stats.trends.pendingLeads, sparklineField: "pending" as const },
|
||||
{ title: "Closed", value: stats.closedLeads, icon: CheckCircle2, description: "Won", trend: stats.trends.closedLeads, sparklineField: "closed" as const },
|
||||
{ title: "Conversion Rate", value: `${stats.conversionRate}%`, icon: TrendingUp, description: `${stats.closedLeads} of ${stats.totalLeads} leads`, trend: stats.trends.conversionRate, sparklineField: "closed" as const, conversionRate: stats.conversionRate },
|
||||
]
|
||||
: []
|
||||
|
||||
return (
|
||||
<div className="space-y-6 relative">
|
||||
<StarField />
|
||||
<div className="relative z-[1] space-y-6">
|
||||
<div>
|
||||
<PageHeader
|
||||
title={<span style={{fontFamily:"'Audiowide',sans-serif"}}>Dashboard</span>}
|
||||
description="Overview of your sales pipeline"
|
||||
>
|
||||
<span
|
||||
className="pointer-events-none select-none text-5xl text-gray-600 dark:text-[#b0f0c0] tracking-wider font-bold text-glow whitespace-nowrap"
|
||||
style={{ fontFamily: "'Cormorant Garamond', serif" }}
|
||||
>
|
||||
Trust the Force, Find your Path
|
||||
</span>
|
||||
<CrossedLightsabers />
|
||||
<Select value={period} onValueChange={setPeriod}>
|
||||
<SelectTrigger className="h-9 w-[160px]">
|
||||
<ListFilter className="mr-2 h-4 w-4" />
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="7days">Last 7 days</SelectItem>
|
||||
<SelectItem value="30days">Last 30 days</SelectItem>
|
||||
<SelectItem value="6months">Last 6 months</SelectItem>
|
||||
<SelectItem value="12months">Last 12 months</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</PageHeader>
|
||||
</div>
|
||||
|
||||
<p className="text-xs font-semibold tracking-widest uppercase text-[#888888] dark:text-[#666666]">Pipeline Overview</p>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
|
||||
{stats
|
||||
? statCards.map((card, i) => (
|
||||
<StatCard key={card.title} {...card} index={i} monthlyBreakdown={stats.monthlyBreakdown} />
|
||||
))
|
||||
: Array.from({ length: 6 }).map((_, i) => <StatCardSkeleton key={i} />)
|
||||
}
|
||||
</div>
|
||||
|
||||
<p className="text-xs font-semibold tracking-widest uppercase text-[#888888] dark:text-[#666666]">Analytics</p>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<LeadStatusChart data={stats?.statusDistribution ?? []} />
|
||||
<LeadsPerMonthChart data={stats?.leadsPerMonth ?? []} />
|
||||
</div>
|
||||
|
||||
<RecentLeadsTable leads={stats?.recentLeads ?? []} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
"use client"
|
||||
|
||||
import { AppShell } from "@/components/layout/app-shell"
|
||||
import { UserProvider, useUser } from "@/providers/user-provider"
|
||||
import { NotificationProvider } from "@/providers/notification-provider"
|
||||
import { ErrorBoundary } from "@/components/shared/error-boundary"
|
||||
import { Loader2 } from "lucide-react"
|
||||
|
||||
function DashboardContent({ children }: { children: React.ReactNode }) {
|
||||
const { user, loading } = useUser()
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!user) return null
|
||||
|
||||
return <AppShell>{children}</AppShell>
|
||||
}
|
||||
|
||||
export default function DashboardLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<UserProvider>
|
||||
<NotificationProvider>
|
||||
<ErrorBoundary>
|
||||
<DashboardContent>{children}</DashboardContent>
|
||||
</ErrorBoundary>
|
||||
</NotificationProvider>
|
||||
</UserProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect, useCallback } from "react"
|
||||
import Link from "next/link"
|
||||
import { motion } from "framer-motion"
|
||||
import { use } from "react"
|
||||
import { PageHeader } from "@/components/shared/page-header"
|
||||
import { LeadDetailsCard } from "@/components/leads/lead-details-card"
|
||||
import { LeadStatusBadge } from "@/components/leads/lead-status-badge"
|
||||
import { NoteTimeline } from "@/components/notes/note-timeline"
|
||||
import { NoteForm } from "@/components/notes/note-form"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { ArrowLeft, Edit, ExternalLink } from "lucide-react"
|
||||
import { Lead, Note } from "@/types"
|
||||
|
||||
export default function LeadDetailsPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = use(params)
|
||||
const [lead, setLead] = useState<Lead | null>(null)
|
||||
const [leadNotes, setLeadNotes] = useState<Note[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const fetchNotes = useCallback(async () => {
|
||||
const notesRes = await fetch(`/api/leads/${id}/notes`)
|
||||
if (notesRes.ok) setLeadNotes(await notesRes.json())
|
||||
}, [id])
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchData() {
|
||||
try {
|
||||
const [leadRes] = await Promise.all([
|
||||
fetch(`/api/leads/${id}`),
|
||||
fetchNotes(),
|
||||
])
|
||||
if (leadRes.ok) setLead(await leadRes.json())
|
||||
} catch {
|
||||
console.warn("Failed to fetch lead details")
|
||||
}
|
||||
setLoading(false)
|
||||
}
|
||||
fetchData()
|
||||
}, [id, fetchNotes])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-[60vh]">
|
||||
<p className="text-muted-foreground">Loading...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!lead) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-[60vh]">
|
||||
<div className="text-center">
|
||||
<h2 className="text-2xl font-bold">Lead not found</h2>
|
||||
<p className="mt-2 text-muted-foreground">The lead you are looking for does not exist.</p>
|
||||
<Link href="/leads" className="mt-4 inline-block">
|
||||
<Button variant="outline">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Leads
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Link
|
||||
href="/leads"
|
||||
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back to leads
|
||||
</Link>
|
||||
|
||||
<PageHeader
|
||||
title={
|
||||
<div className="flex items-center gap-3">
|
||||
<span>{lead.companyName}</span>
|
||||
<LeadStatusBadge status={lead.status} />
|
||||
</div>
|
||||
}
|
||||
description={lead.contactName}
|
||||
>
|
||||
<Select
|
||||
value={lead.status}
|
||||
onValueChange={async (v) => {
|
||||
const previousStatus = lead.status
|
||||
setLead((prev) => prev ? { ...prev, status: v as Lead["status"] } : prev)
|
||||
try {
|
||||
const res = await fetch(`/api/leads/${id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ status: v }),
|
||||
})
|
||||
if (!res.ok) setLead((prev) => prev ? { ...prev, status: previousStatus } : prev)
|
||||
} catch {
|
||||
setLead((prev) => prev ? { ...prev, status: previousStatus } : prev)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-9 w-[140px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="open">Open</SelectItem>
|
||||
<SelectItem value="contacted">Contacted</SelectItem>
|
||||
<SelectItem value="pending">Pending</SelectItem>
|
||||
<SelectItem value="closed">Closed</SelectItem>
|
||||
<SelectItem value="ignored">Ignored</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button variant="outline" size="sm">
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</Button>
|
||||
</PageHeader>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<LeadDetailsCard lead={lead} />
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, delay: 0.1 }}
|
||||
>
|
||||
<NoteForm leadId={lead.id} onNoteAdded={fetchNotes} />
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, delay: 0.2 }}
|
||||
>
|
||||
<NoteTimeline notes={leadNotes} />
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.3, delay: 0.15 }}
|
||||
className="rounded-lg border bg-card p-6"
|
||||
>
|
||||
<h3 className="font-semibold mb-4">Activity</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<div className="h-2 w-2 rounded-full bg-blue-500" />
|
||||
<div>
|
||||
<p className="font-medium">Lead Created</p>
|
||||
<p className="text-xs text-muted-foreground">{new Date(lead.createdAt).toLocaleDateString()}</p>
|
||||
</div>
|
||||
</div>
|
||||
{leadNotes.slice(0, 3).map((note) => (
|
||||
<div key={note.id} className="flex items-start gap-3 text-sm">
|
||||
<div className="h-2 w-2 rounded-full bg-muted-foreground mt-1.5" />
|
||||
<div>
|
||||
<p className="font-medium">{note.authorName}</p>
|
||||
<p className="text-xs text-muted-foreground">{note.note.slice(0, 60)}...</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.3, delay: 0.25 }}
|
||||
className="rounded-lg border bg-card p-6"
|
||||
>
|
||||
<h3 className="font-semibold mb-4">Quick Actions</h3>
|
||||
<div className="space-y-2">
|
||||
<Button variant="outline" className="w-full justify-start" size="sm">
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
Send Email
|
||||
</Button>
|
||||
<Button variant="outline" className="w-full justify-start" size="sm">
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
Call Lead
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import * as z from "zod"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { ArrowLeft } from "lucide-react"
|
||||
import { User } from "@/types"
|
||||
import { useNotifications } from "@/providers/notification-provider"
|
||||
import { LEAD_SOURCES, LEAD_STATUSES } from "@/lib/constants"
|
||||
|
||||
const leadFormSchema = z.object({
|
||||
companyName: z.string().min(1, "Company name is required"),
|
||||
contactName: z.string().min(1, "Contact name is required"),
|
||||
email: z.string().email("Invalid email address"),
|
||||
phone: z.string().optional(),
|
||||
source: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
status: z.string(),
|
||||
assignedUserId: z.string().optional(),
|
||||
})
|
||||
|
||||
type LeadFormValues = z.infer<typeof leadFormSchema>
|
||||
|
||||
export default function CreateLeadPage() {
|
||||
const router = useRouter()
|
||||
const { addNotification } = useNotifications()
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [users, setUsers] = useState<User[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/users")
|
||||
.then((r) => r.json())
|
||||
.then((data) => setUsers(data.users || []))
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
const form = useForm<LeadFormValues>({
|
||||
resolver: zodResolver(leadFormSchema),
|
||||
defaultValues: {
|
||||
companyName: "",
|
||||
contactName: "",
|
||||
email: "",
|
||||
phone: "",
|
||||
source: "",
|
||||
description: "",
|
||||
status: "open",
|
||||
assignedUserId: "none",
|
||||
},
|
||||
})
|
||||
|
||||
async function onSubmit(values: LeadFormValues) {
|
||||
setSaving(true)
|
||||
try {
|
||||
const payload = {
|
||||
...values,
|
||||
assignedUserId: values.assignedUserId === "none" ? null : (values.assignedUserId ?? null),
|
||||
}
|
||||
const res = await fetch("/api/leads", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
if (!res.ok) throw new Error("Failed to create lead")
|
||||
const data = await res.json()
|
||||
addNotification("lead_created", "New Lead Created", `${values.companyName} — ${values.contactName}`, `/leads/${data.id}`)
|
||||
router.push("/leads")
|
||||
} catch (e) {
|
||||
console.error("Create lead error:", e)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Link
|
||||
href="/leads"
|
||||
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back to leads
|
||||
</Link>
|
||||
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Create New Lead</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">Fill in the details to add a new lead.</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="companyName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Company Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Acme Corp" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="contactName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Contact Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="John Doe" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="john@acme.com" type="email" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="phone"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Phone</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="(555) 123-4567" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="source"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Source</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select source" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{LEAD_SOURCES.map((source) => (
|
||||
<SelectItem key={source} value={source}>
|
||||
{source}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="status"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Status</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select status" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{Object.entries(LEAD_STATUSES).map(([key, { label }]) => (
|
||||
<SelectItem key={key} value={key}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Description</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Brief description of the lead and their requirements..."
|
||||
className="min-h-[100px]"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="assignedUserId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Assign To</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select user" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">Unassigned</SelectItem>
|
||||
{users.filter((u) => u.active).map((user) => (
|
||||
<SelectItem key={user.id} value={user.id}>
|
||||
{user.name} ({user.role})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="flex items-center gap-3 pt-2">
|
||||
<Button type="submit" disabled={saving}>
|
||||
{saving ? "Saving..." : "Create Lead"}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => router.push("/leads")}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { PageHeader } from "@/components/shared/page-header"
|
||||
import { LeadsTable } from "@/components/leads/leads-table"
|
||||
import { LeadsTableToolbar } from "@/components/leads/leads-table-toolbar"
|
||||
import { Lead } from "@/types"
|
||||
|
||||
export default function LeadsPage() {
|
||||
const router = useRouter()
|
||||
const [search, setSearch] = useState("")
|
||||
const [statusFilter, setStatusFilter] = useState("all")
|
||||
const [periodFilter, setPeriodFilter] = useState("all")
|
||||
const [leadsData, setLeadsData] = useState<Lead[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams()
|
||||
if (search) params.set("search", search)
|
||||
if (statusFilter !== "all") params.set("status", statusFilter)
|
||||
if (periodFilter !== "all") params.set("period", periodFilter)
|
||||
|
||||
setLoading(true)
|
||||
fetch(`/api/leads?${params.toString()}`)
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
setLeadsData(data)
|
||||
setLoading(false)
|
||||
})
|
||||
.catch(() => setLoading(false))
|
||||
}, [search, statusFilter, periodFilter])
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<PageHeader
|
||||
title="Leads"
|
||||
description="Manage and track your sales leads"
|
||||
/>
|
||||
|
||||
<div className="rounded-lg border bg-card">
|
||||
<div className="p-4">
|
||||
<LeadsTableToolbar
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
statusFilter={statusFilter}
|
||||
onStatusFilterChange={setStatusFilter}
|
||||
periodFilter={periodFilter}
|
||||
onPeriodFilterChange={setPeriodFilter}
|
||||
onCreateClick={() => router.push("/leads/new")}
|
||||
/>
|
||||
</div>
|
||||
<LeadsTable data={leadsData} loading={loading} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
"use client"
|
||||
|
||||
import { useRef } from "react"
|
||||
import { PageHeader } from "@/components/shared/page-header"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { useUser } from "@/providers/user-provider"
|
||||
import { Mail, Calendar, Shield, Activity, Camera } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
export default function ProfilePage() {
|
||||
const { user, updateAvatar } = useUser()
|
||||
if (!user) return null
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const handleAvatarChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
const validTypes = ["image/png", "image/jpeg"]
|
||||
if (!validTypes.includes(file.type)) {
|
||||
toast.error("Only PNG and JPEG files are allowed")
|
||||
return
|
||||
}
|
||||
const reader = new FileReader()
|
||||
reader.onload = async () => {
|
||||
const dataUrl = reader.result as string
|
||||
try {
|
||||
const res = await fetch("/api/users/avatar", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ avatar: dataUrl }),
|
||||
})
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
updateAvatar(data.avatar)
|
||||
toast.success("Avatar updated")
|
||||
} else {
|
||||
toast.error("Failed to save avatar")
|
||||
}
|
||||
} catch {
|
||||
console.warn("Failed to save avatar")
|
||||
toast.error("Failed to save avatar")
|
||||
}
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader title="Profile" description="Your account information" />
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
<Card className="lg:col-span-1">
|
||||
<CardContent className="flex flex-col items-center pt-8">
|
||||
<div className="relative">
|
||||
<Avatar className="h-24 w-24">
|
||||
<AvatarImage src={user.avatar} />
|
||||
<AvatarFallback className="text-2xl">{user.name.split(" ").map((n) => n[0]).join("")}</AvatarFallback>
|
||||
</Avatar>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="absolute inset-0 flex items-center justify-center rounded-full bg-black/40 opacity-0 transition-opacity hover:opacity-100"
|
||||
>
|
||||
<Camera className="h-6 w-6 text-white" />
|
||||
</button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".png,.jpeg,.jpg"
|
||||
className="hidden"
|
||||
onChange={handleAvatarChange}
|
||||
/>
|
||||
</div>
|
||||
<h2 className="mt-4 text-xl font-semibold">{user.name}</h2>
|
||||
<Badge variant="secondary" className="mt-1 capitalize">
|
||||
{user.role}
|
||||
</Badge>
|
||||
<div className="mt-6 flex w-full flex-col gap-3 text-sm">
|
||||
<div className="flex items-center gap-3 rounded-lg bg-muted/50 px-4 py-3">
|
||||
<Mail className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">{user.email}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 rounded-lg bg-muted/50 px-4 py-3">
|
||||
<Calendar className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">
|
||||
Joined {new Date(user.createdAt).toLocaleDateString("en-US", { month: "long", year: "numeric" })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 rounded-lg bg-muted/50 px-4 py-3">
|
||||
<Shield className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-muted-foreground capitalize">{user.role} access</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 rounded-lg bg-muted/50 px-4 py-3">
|
||||
<Activity className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">{user.active ? "Active" : "Inactive"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Account Details</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground">Full Name</span>
|
||||
<p className="text-sm font-medium">{user.name}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground">Email</span>
|
||||
<p className="text-sm font-medium">{user.email}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground">Role</span>
|
||||
<p className="text-sm font-medium capitalize">{user.role}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground">Status</span>
|
||||
<p className="text-sm font-medium">{user.active ? "Active" : "Inactive"}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground">Member Since</span>
|
||||
<p className="text-sm font-medium">
|
||||
{new Date(user.createdAt).toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground">User ID</span>
|
||||
<p className="text-sm font-medium font-mono">{user.id}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
"use client"
|
||||
|
||||
import { PageHeader } from "@/components/shared/page-header"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { CompanySettingsForm } from "@/components/settings/company-settings-form"
|
||||
import { UserPreferencesForm } from "@/components/settings/user-preferences-form"
|
||||
import { ThemeSettings } from "@/components/settings/theme-settings"
|
||||
import { NotificationSettings } from "@/components/settings/notification-settings"
|
||||
import { Building2, User, Palette, Bell } from "lucide-react"
|
||||
|
||||
export default function SettingsPage() {
|
||||
const tabs = [
|
||||
{ value: "company", label: "Company", icon: Building2, component: CompanySettingsForm },
|
||||
{ value: "preferences", label: "Preferences", icon: User, component: UserPreferencesForm },
|
||||
{ value: "theme", label: "Theme", icon: Palette, component: ThemeSettings },
|
||||
{ value: "notifications", label: "Notifications", icon: Bell, component: NotificationSettings },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<PageHeader
|
||||
title="Settings"
|
||||
description="Manage your CRM configuration and preferences"
|
||||
/>
|
||||
|
||||
<Tabs defaultValue="company" className="space-y-6">
|
||||
<TabsList>
|
||||
{tabs.map((tab) => (
|
||||
<TabsTrigger key={tab.value} value={tab.value} className="gap-2">
|
||||
<tab.icon className="h-4 w-4" />
|
||||
{tab.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
{tabs.map((tab) => (
|
||||
<TabsContent key={tab.value} value={tab.value}>
|
||||
<tab.component />
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect, useCallback } from "react"
|
||||
import { PageHeader } from "@/components/shared/page-header"
|
||||
import { UsersTable } from "@/components/users/users-table"
|
||||
import { UserFormDialog } from "@/components/users/user-form-dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { useUser } from "@/providers/user-provider"
|
||||
import { Plus, Users as UsersIcon, Search } from "lucide-react"
|
||||
import type { User } from "@/types"
|
||||
|
||||
export default function UsersPage() {
|
||||
const { user } = useUser()
|
||||
const [users, setUsers] = useState<User[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [search, setSearch] = useState("")
|
||||
|
||||
const fetchUsers = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/users")
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setUsers(data.users || [])
|
||||
}
|
||||
} catch {
|
||||
console.warn("Failed to fetch users in users page")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers()
|
||||
}, [fetchUsers])
|
||||
|
||||
const activeUsers = users.filter((u) => u.active !== false)
|
||||
const admins = users.filter((u) => u.role === "admin")
|
||||
|
||||
const stats = [
|
||||
{ label: "Total Users", value: users.length, color: "bg-blue-500/10", textColor: "text-blue-500" },
|
||||
{ label: "Active", value: activeUsers.length, color: "bg-green-500/10", textColor: "text-green-500" },
|
||||
{ label: "Admins", value: admins.length, color: "bg-purple-500/10", textColor: "text-purple-500" },
|
||||
{ label: "Sales", value: users.filter((u) => u.role === "sales").length, color: "bg-orange-500/10", textColor: "text-orange-500" },
|
||||
]
|
||||
|
||||
const filtered = users.filter((u) => {
|
||||
if (!search) return true
|
||||
const q = search.toLowerCase()
|
||||
return u.name?.toLowerCase().includes(q) || u.email?.toLowerCase().includes(q)
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<PageHeader
|
||||
title="Users"
|
||||
description="Manage team members and their roles"
|
||||
>
|
||||
<Button className="gap-2" onClick={() => setCreateOpen(true)}>
|
||||
<Plus className="h-4 w-4" />
|
||||
Add User
|
||||
</Button>
|
||||
</PageHeader>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4 mb-6">
|
||||
{stats.map((s) => (
|
||||
<div key={s.label} className="rounded-lg border bg-card p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={`flex h-10 w-10 items-center justify-center rounded-lg ${s.color}`}
|
||||
>
|
||||
<UsersIcon className={`h-5 w-5 ${s.textColor}`} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold">{s.value}</p>
|
||||
<p className="text-xs text-muted-foreground">{s.label}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search by name or email..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="h-9 pl-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border bg-card">
|
||||
<UsersTable data={filtered} loading={loading} onUserDeleted={fetchUsers} />
|
||||
</div>
|
||||
|
||||
<UserFormDialog
|
||||
open={createOpen}
|
||||
onOpenChange={setCreateOpen}
|
||||
onUserCreated={fetchUsers}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user