Merge: resolve topbar conflict, align types with DB schema

This commit is contained in:
2026-06-18 13:48:58 +02:00
10 changed files with 205 additions and 77 deletions
+28 -17
View File
@@ -1,12 +1,13 @@
"use client"
import { useState } from "react"
import { useState, useEffect } 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 { dashboardStats } from "@/data/dashboard"
import { getDashboardStats } from "@/data/dashboard"
import {
Users,
UserPlus,
@@ -23,19 +24,26 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { DashboardStats } from "@/types"
export default function DashboardPage() {
const stats = dashboardStats
const [period, setPeriod] = useState("6months")
const [stats, setStats] = useState<DashboardStats | null>(null)
const statCards = [
{ title: "Total Leads", value: stats.totalLeads, icon: Users, variant: "blue" as const, description: "All time" },
{ title: "Open Leads", value: stats.openLeads, icon: UserPlus, variant: "blue" as const, description: "Needs attention" },
{ title: "Contacted", value: stats.contactedLeads, icon: PhoneCall, variant: "amber" as const, description: "In conversation" },
{ title: "Pending", value: stats.pendingLeads, icon: Clock, variant: "purple" as const, description: "Awaiting decision" },
{ title: "Closed", value: stats.closedLeads, icon: CheckCircle2, variant: "emerald" as const, description: "Won" },
{ title: "Conversion Rate", value: `${stats.conversionRate}%`, icon: TrendingUp, variant: "emerald" as const, description: `${stats.closedLeads} of ${stats.totalLeads} leads` },
]
useEffect(() => {
setStats(getDashboardStats(period))
}, [period])
const statCards = stats
? [
{ title: "Total Leads", value: stats.totalLeads, icon: Users, variant: "blue" as const, description: stats.periodLabel },
{ title: "Open Leads", value: stats.openLeads, icon: UserPlus, variant: "blue" as const, description: "Needs attention" },
{ title: "Contacted", value: stats.contactedLeads, icon: PhoneCall, variant: "amber" as const, description: "In conversation" },
{ title: "Pending", value: stats.pendingLeads, icon: Clock, variant: "purple" as const, description: "Awaiting decision" },
{ title: "Closed", value: stats.closedLeads, icon: CheckCircle2, variant: "emerald" as const, description: "Won" },
{ title: "Conversion Rate", value: `${stats.conversionRate}%`, icon: TrendingUp, variant: "emerald" as const, description: `${stats.closedLeads} of ${stats.totalLeads} leads` },
]
: []
return (
<div className="space-y-6">
@@ -55,17 +63,20 @@ export default function DashboardPage() {
</PageHeader>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
{statCards.map((card, i) => (
<StatCard key={card.title} {...card} index={i} />
))}
{stats
? statCards.map((card, i) => (
<StatCard key={card.title} {...card} index={i} />
))
: Array.from({ length: 6 }).map((_, i) => <StatCardSkeleton key={i} />)
}
</div>
<div className="grid gap-6 lg:grid-cols-2">
<LeadStatusChart data={stats.statusDistribution} />
<LeadsPerMonthChart data={stats.leadsPerMonth} />
<LeadStatusChart data={stats?.statusDistribution ?? []} />
<LeadsPerMonthChart data={stats?.leadsPerMonth ?? []} />
</div>
<RecentLeadsTable leads={stats.recentLeads} />
<RecentLeadsTable leads={stats?.recentLeads ?? []} />
</div>
)
}
+9 -1
View File
@@ -6,15 +6,21 @@ import { LeadsTable } from "@/components/leads/leads-table"
import { LeadsTableToolbar } from "@/components/leads/leads-table-toolbar"
import { LeadFormDialog } from "@/components/leads/lead-form-dialog"
import { leads } from "@/data/leads"
import { filterLeadsByPeriod } from "@/lib/date-utils"
export default function LeadsPage() {
const [search, setSearch] = useState("")
const [statusFilter, setStatusFilter] = useState("all")
const [periodFilter, setPeriodFilter] = useState("all")
const [createOpen, setCreateOpen] = useState(false)
const filteredLeads = useMemo(() => {
let result = leads
if (periodFilter && periodFilter !== "all") {
result = filterLeadsByPeriod(result, periodFilter)
}
if (search) {
const q = search.toLowerCase()
result = result.filter(
@@ -31,7 +37,7 @@ export default function LeadsPage() {
}
return result
}, [search, statusFilter])
}, [search, statusFilter, periodFilter])
return (
<div className="space-y-4">
@@ -47,6 +53,8 @@ export default function LeadsPage() {
onSearchChange={setSearch}
statusFilter={statusFilter}
onStatusFilterChange={setStatusFilter}
periodFilter={periodFilter}
onPeriodFilterChange={setPeriodFilter}
onCreateClick={() => setCreateOpen(true)}
/>
</div>
@@ -4,14 +4,14 @@ import { useState, useMemo } from "react"
import { motion } from "framer-motion"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
interface MonthlyData {
month: string
interface IntervalData {
label: string
leads: number
closed: number
}
interface LeadsPerMonthChartProps {
data: MonthlyData[]
data: IntervalData[]
}
const BAR_GRADIENT_TOP = "#3B6AB8"
@@ -138,7 +138,7 @@ export function LeadsPerMonthChart({ data }: LeadsPerMonthChartProps) {
return (
<g
key={d.month}
key={d.label}
onMouseEnter={() => setHovered(i)}
onMouseLeave={() => setHovered(null)}
style={{ cursor: "pointer" }}
@@ -188,7 +188,7 @@ export function LeadsPerMonthChart({ data }: LeadsPerMonthChartProps) {
fontWeight={isActive ? 600 : 400}
textAnchor="middle"
>
{d.month}
{d.label}
</text>
</g>
)
@@ -216,7 +216,7 @@ export function LeadsPerMonthChart({ data }: LeadsPerMonthChartProps) {
}}
>
<div className="mb-1.5 text-xs font-semibold" style={{ color: "hsl(var(--foreground))" }}>
{activeDatum.month}
{activeDatum.label}
</div>
<div className="mb-1 flex items-center justify-between gap-4 text-xs" style={{ color: "hsl(var(--muted-foreground))" }}>
<span className="flex items-center gap-1.5">
+1 -1
View File
@@ -41,7 +41,7 @@ export function Topbar({ onMenuClick }: TopbarProps) {
useEffect(() => { setMounted(true) }, [])
if (!user) return null
const initials = user.name.split(" ").map((n) => n[0]).join("")
const initials = user.name.split(" ").map((n: string) => n[0]).join("").toUpperCase()
return (
<header className="sticky top-0 z-20 flex h-16 items-center gap-4 border-b bg-background px-4 lg:px-6">
+17 -7
View File
@@ -1,6 +1,5 @@
"use client"
import { useState } from "react"
import { Input } from "@/components/ui/input"
import { Button } from "@/components/ui/button"
import {
@@ -10,14 +9,15 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Search, Plus, SlidersHorizontal } from "lucide-react"
import { LeadStatus } from "@/types"
import { Search, Plus } from "lucide-react"
interface LeadsTableToolbarProps {
search: string
onSearchChange: (value: string) => void
statusFilter: string
onStatusFilterChange: (value: string) => void
periodFilter: string
onPeriodFilterChange: (value: string) => void
onCreateClick: () => void
}
@@ -26,6 +26,8 @@ export function LeadsTableToolbar({
onSearchChange,
statusFilter,
onStatusFilterChange,
periodFilter,
onPeriodFilterChange,
onCreateClick,
}: LeadsTableToolbarProps) {
return (
@@ -40,6 +42,18 @@ export function LeadsTableToolbar({
/>
</div>
<div className="flex items-center gap-3">
<Select value={periodFilter} onValueChange={onPeriodFilterChange}>
<SelectTrigger className="h-9 w-[150px]">
<SelectValue placeholder="All Time" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Time</SelectItem>
<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>
<Select value={statusFilter} onValueChange={onStatusFilterChange}>
<SelectTrigger className="h-9 w-[140px]">
<SelectValue placeholder="All Statuses" />
@@ -53,10 +67,6 @@ export function LeadsTableToolbar({
<SelectItem value="ignored">Ignored</SelectItem>
</SelectContent>
</Select>
<Button size="sm" variant="outline" className="h-9 gap-2">
<SlidersHorizontal className="h-4 w-4" />
<span className="hidden sm:inline">Filters</span>
</Button>
<Button size="sm" className="h-9 gap-2" onClick={onCreateClick}>
<Plus className="h-4 w-4" />
<span className="hidden sm:inline">Create Lead</span>
+45 -43
View File
@@ -1,52 +1,54 @@
import { DashboardStats } from "@/types"
import { leads } from "./leads"
import { filterLeadsByPeriod, groupLeadsByInterval } from "@/lib/date-utils"
function getLeadsPerMonth() {
const months: { month: string; leads: number; closed: number }[] = []
const now = new Date()
const periodLabels: Record<string, string> = {
"7days": "Last 7 days",
"30days": "Last 30 days",
"6months": "Last 6 months",
"12months": "Last 12 months",
}
for (let i = 5; i >= 0; i--) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1)
const monthStr = d.toLocaleDateString("en-US", { month: "short", year: "2-digit" })
const yearMonth = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`
export function getDashboardStats(period: string): DashboardStats {
const filtered = filterLeadsByPeriod(leads, period)
const label = periodLabels[period] ?? "Selected period"
const leadsCount = leads.filter((l) => l.createdAt.startsWith(yearMonth)).length
const closedCount = leads.filter(
(l) => l.status === "closed" && l.updatedAt.startsWith(yearMonth)
).length
const totalLeads = filtered.length
const openLeads = filtered.filter((l) => l.status === "open").length
const contactedLeads = filtered.filter((l) => l.status === "contacted").length
const pendingLeads = filtered.filter((l) => l.status === "pending").length
const closedLeads = filtered.filter((l) => l.status === "closed").length
const ignoredLeads = filtered.filter((l) => l.status === "ignored").length
const conversionRate = totalLeads > 0
? Math.round((closedLeads / totalLeads) * 100)
: 0
months.push({ month: monthStr, leads: leadsCount, closed: closedCount })
function getStatusDistribution() {
const statusCounts: Record<string, number> = { open: 0, contacted: 0, pending: 0, closed: 0, ignored: 0 }
filtered.forEach((l) => {
statusCounts[l.status]++
})
return [
{ name: "Open", value: statusCounts.open, color: "#3b82f6" },
{ name: "Contacted", value: statusCounts.contacted, color: "#f59e0b" },
{ name: "Pending", value: statusCounts.pending, color: "#8b5cf6" },
{ name: "Closed", value: statusCounts.closed, color: "#10b981" },
{ name: "Ignored", value: statusCounts.ignored, color: "#6B7280" },
]
}
return months
}
function getStatusDistribution() {
const statusCounts: Record<string, number> = { open: 0, contacted: 0, pending: 0, closed: 0, ignored: 0 }
leads.forEach((l) => {
statusCounts[l.status]++
})
return [
{ name: "Open", value: statusCounts.open, color: "#3b82f6" },
{ name: "Contacted", value: statusCounts.contacted, color: "#f59e0b" },
{ name: "Pending", value: statusCounts.pending, color: "#8b5cf6" },
{ name: "Closed", value: statusCounts.closed, color: "#10b981" },
{ name: "Ignored", value: statusCounts.ignored, color: "#6B7280" },
]
}
export const dashboardStats: DashboardStats = {
totalLeads: leads.length,
openLeads: leads.filter((l) => l.status === "open").length,
contactedLeads: leads.filter((l) => l.status === "contacted").length,
pendingLeads: leads.filter((l) => l.status === "pending").length,
closedLeads: leads.filter((l) => l.status === "closed").length,
ignoredLeads: leads.filter((l) => l.status === "ignored").length,
conversionRate: Math.round(
(leads.filter((l) => l.status === "closed").length / leads.length) * 100
),
leadsPerMonth: getLeadsPerMonth(),
recentLeads: leads.slice(0, 10),
statusDistribution: getStatusDistribution(),
return {
totalLeads,
openLeads,
contactedLeads,
pendingLeads,
closedLeads,
ignoredLeads,
conversionRate,
leadsPerMonth: groupLeadsByInterval(filtered, period),
recentLeads: filtered.slice(0, 10),
statusDistribution: getStatusDistribution(),
periodLabel: label,
}
}
+95
View File
@@ -0,0 +1,95 @@
import { Lead } from "@/types"
export function getPeriodDateRange(period: string): { start: Date; end: Date } {
const end = new Date()
const start = new Date()
switch (period) {
case "7days":
start.setDate(start.getDate() - 7)
break
case "30days":
start.setDate(start.getDate() - 30)
break
case "6months":
start.setMonth(start.getMonth() - 6)
break
case "12months":
start.setMonth(start.getMonth() - 12)
break
default:
start.setMonth(start.getMonth() - 6)
}
return { start, end }
}
export function filterLeadsByPeriod(leads: Lead[], period: string): Lead[] {
const { start, end } = getPeriodDateRange(period)
return leads.filter((l) => {
const d = new Date(l.createdAt)
return d >= start && d <= end
})
}
export function groupLeadsByInterval(
leads: Lead[],
period: string
): { label: string; leads: number; closed: number }[] {
const { start, end } = getPeriodDateRange(period)
if (period === "7days" || period === "30days") {
return groupByDay(leads, start, end)
}
return groupByMonth(leads, start, end)
}
function groupByDay(
leads: Lead[],
start: Date,
end: Date
): { label: string; leads: number; closed: number }[] {
const result: { label: string; leads: number; closed: number }[] = []
const current = new Date(start)
while (current <= end) {
const dateStr = current.toISOString().slice(0, 10)
const dayLeads = leads.filter((l) => l.createdAt.startsWith(dateStr))
const closedCount = dayLeads.filter((l) => l.status === "closed").length
result.push({
label: current.toLocaleDateString("en-US", { month: "short", day: "numeric" }),
leads: dayLeads.length,
closed: closedCount,
})
current.setDate(current.getDate() + 1)
}
return result
}
function groupByMonth(
leads: Lead[],
start: Date,
end: Date
): { label: string; leads: number; closed: number }[] {
const result: { label: string; leads: number; closed: number }[] = []
const current = new Date(start.getFullYear(), start.getMonth(), 1)
while (current <= end) {
const yearMonth = `${current.getFullYear()}-${String(current.getMonth() + 1).padStart(2, "0")}`
const monthLeads = leads.filter((l) => l.createdAt.startsWith(yearMonth))
const closedCount = monthLeads.filter((l) => l.status === "closed").length
result.push({
label: current.toLocaleDateString("en-US", { month: "short", year: "2-digit" }),
leads: monthLeads.length,
closed: closedCount,
})
current.setMonth(current.getMonth() + 1)
}
return result
}
+2 -1
View File
@@ -46,9 +46,10 @@ export interface DashboardStats {
closedLeads: number
ignoredLeads: number
conversionRate: number
leadsPerMonth: { month: string; leads: number; closed: number }[]
leadsPerMonth: { label: string; leads: number; closed: number }[]
recentLeads: Lead[]
statusDistribution: { name: string; value: number; color: string }[]
periodLabel: string
}
export interface ColumnFilter {