mirror of
https://git.coastit.co.za/caitlin/CRM_ENVR.git
synced 2026-07-10 11:15:43 +02:00
adbcc4b9af
feat: implement conversations API with message retrieval and posting feat: add avatar URL handling for users and update user role definitions feat: create chat system tables in the database with initial seed data fix: update user roles from "sales_user" to "sales" for consistency chore: add middleware for system API route access fixed fuckups on john's side, added a benchmark Made Graphs actualy load from database and realtime data, graphs will update and percentages will be mathematically made, and made realtime added chatcart edits, made graphs glow. added in some little small home feeling fuctions added in a slide show, in login with qoutes from creators because i want to leave a print on it saying we made this shit, we built it brick for brick login page added qoutes some random, and made them shuffle from left to right one dissapears other come in adding shape to the textbox for the qoutes Fixed my chat fuckups when it comes to chats
312 lines
12 KiB
TypeScript
312 lines
12 KiB
TypeScript
"use client"
|
|
|
|
import { useState, useMemo, useEffect } from "react"
|
|
import { motion } from "framer-motion"
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
|
import { Button } from "@/components/ui/button"
|
|
import { ChevronLeft, ChevronRight } from "lucide-react"
|
|
|
|
interface IntervalData {
|
|
label: string
|
|
leads: number
|
|
closed: number
|
|
}
|
|
|
|
interface LeadsPerMonthChartProps {
|
|
data: IntervalData[]
|
|
}
|
|
|
|
const NEW_LEADS = "#0d9488"
|
|
const CLOSED = "#c9a96e"
|
|
|
|
export function LeadsPerMonthChart({ data: initialData }: LeadsPerMonthChartProps) {
|
|
const [year, setYear] = useState(new Date().getFullYear())
|
|
const [chartData, setChartData] = useState<IntervalData[]>(initialData)
|
|
const [hovered, setHovered] = useState<number | null>(null)
|
|
|
|
useEffect(() => {
|
|
setChartData(initialData)
|
|
}, [initialData])
|
|
|
|
useEffect(() => {
|
|
async function fetchYearData() {
|
|
try {
|
|
const res = await fetch(`/api/dashboard?year=${year}`)
|
|
if (res.ok) {
|
|
const data = await res.json()
|
|
setChartData(data.leadsPerMonth || [])
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
const thisYear = new Date().getFullYear()
|
|
if (year !== thisYear || initialData.length === 0) {
|
|
fetchYearData()
|
|
} else {
|
|
setChartData(initialData)
|
|
}
|
|
}, [year, initialData])
|
|
|
|
const width = 880
|
|
const height = 620
|
|
const padding = { top: 128, right: 24, bottom: 48, left: 44 }
|
|
const chartW = width - padding.left - padding.right
|
|
const chartH = height - padding.top - padding.bottom
|
|
|
|
const maxVal = useMemo(() => {
|
|
if (chartData.length === 0) return 1
|
|
const max = Math.max(...chartData.map((d) => Math.max(d.leads, d.closed)))
|
|
return Math.max(Math.ceil(max / 7) * 7, 1)
|
|
}, [chartData])
|
|
|
|
const ticks = useMemo(() => {
|
|
const steps = 4
|
|
return Array.from({ length: steps + 1 }, (_, i) => Math.round((maxVal / steps) * i))
|
|
}, [maxVal])
|
|
|
|
const groupW = chartW / Math.max(chartData.length, 1)
|
|
const barW = groupW * 0.28
|
|
const gap = groupW * 0.06
|
|
|
|
const yScale = (v: number) => chartH - (v / maxVal) * chartH
|
|
|
|
const active = hovered
|
|
const activeDatum = active !== null ? chartData[active] : null
|
|
|
|
const totalNew = chartData.reduce((s, d) => s + d.leads, 0)
|
|
const totalClosed = chartData.reduce((s, d) => s + d.closed, 0)
|
|
const closeRate = totalNew > 0 ? Math.round((totalClosed / totalNew) * 100) : 0
|
|
|
|
const currentYear = new Date().getFullYear()
|
|
|
|
if (chartData.length === 0) {
|
|
return (
|
|
<Card className="h-full">
|
|
<CardHeader>
|
|
<div className="flex items-center justify-between">
|
|
<CardTitle>Leads Per Month</CardTitle>
|
|
<div className="flex items-center gap-1">
|
|
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => setYear((y) => y - 1)}>
|
|
<ChevronLeft className="h-4 w-4" />
|
|
</Button>
|
|
<span className="min-w-[60px] text-center text-sm font-medium">{year}</span>
|
|
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => setYear((y) => Math.min(y + 1, currentYear))} disabled={year >= currentYear}>
|
|
<ChevronRight className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="flex items-center justify-center h-[400px] text-sm text-muted-foreground">
|
|
No data for {year}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 20 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ duration: 0.3, delay: 0.3 }}
|
|
className="h-full"
|
|
>
|
|
<Card className="h-full">
|
|
<CardHeader>
|
|
<div className="flex items-start justify-between">
|
|
<div>
|
|
<CardTitle>Leads Per Month</CardTitle>
|
|
<p className="mt-1 text-sm text-muted-foreground">
|
|
{totalNew} new · {totalClosed} closed · {closeRate}% close rate
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center gap-4">
|
|
<div className="flex items-center gap-1.5">
|
|
<span className="inline-block h-2.5 w-2.5 rounded-sm" style={{ background: NEW_LEADS }} />
|
|
<span className="text-xs text-muted-foreground">New Leads</span>
|
|
</div>
|
|
<div className="flex items-center gap-1.5">
|
|
<span className="inline-block h-2.5 w-2.5 rounded-sm" style={{ background: CLOSED }} />
|
|
<span className="text-xs text-muted-foreground">Closed</span>
|
|
</div>
|
|
<div className="ml-2 flex items-center gap-1 rounded-lg border px-2 py-1">
|
|
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => setYear((y) => y - 1)}>
|
|
<ChevronLeft className="h-3.5 w-3.5" />
|
|
</Button>
|
|
<span className="min-w-[50px] text-center text-sm font-semibold">{year}</span>
|
|
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => setYear((y) => Math.min(y + 1, currentYear))} disabled={year >= currentYear}>
|
|
<ChevronRight className="h-3.5 w-3.5" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="flex flex-1 flex-col">
|
|
<div className="relative flex-1">
|
|
<svg
|
|
viewBox={`0 0 ${width} ${height}`}
|
|
width="100%"
|
|
height="100%"
|
|
className="block overflow-visible"
|
|
>
|
|
<defs>
|
|
<linearGradient id="newLeadsGrad" x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="0%" stopColor="#0d9488" />
|
|
<stop offset="100%" stopColor={NEW_LEADS} />
|
|
</linearGradient>
|
|
<linearGradient id="closedGrad" x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="0%" stopColor="#e8d5a3" />
|
|
<stop offset="100%" stopColor={CLOSED} />
|
|
</linearGradient>
|
|
<filter id="shadowNew">
|
|
<feDropShadow dx="0" dy="2" stdDeviation="4" floodColor={NEW_LEADS} floodOpacity="0.4" />
|
|
</filter>
|
|
<filter id="shadowClosed">
|
|
<feDropShadow dx="0" dy="2" stdDeviation="4" floodColor={CLOSED} floodOpacity="0.4" />
|
|
</filter>
|
|
</defs>
|
|
|
|
<g transform={`translate(${padding.left}, ${padding.top})`}>
|
|
{/* Grid lines */}
|
|
{ticks.map((t, i) => (
|
|
<g key={i}>
|
|
<line
|
|
x1={0}
|
|
x2={chartW}
|
|
y1={yScale(t)}
|
|
y2={yScale(t)}
|
|
stroke="hsl(var(--border))"
|
|
strokeDasharray={t === 0 ? "0" : "3 5"}
|
|
strokeWidth={1}
|
|
/>
|
|
<text
|
|
x={-12}
|
|
y={yScale(t)}
|
|
fill="hsl(var(--muted-foreground))"
|
|
fontSize={12}
|
|
textAnchor="end"
|
|
dominantBaseline="middle"
|
|
>
|
|
{t}
|
|
</text>
|
|
</g>
|
|
))}
|
|
|
|
{/* Bars */}
|
|
{chartData.map((d, i) => {
|
|
const groupX = i * groupW
|
|
const isActive = active === i
|
|
const newH = chartH - yScale(d.leads)
|
|
const closedH = chartH - yScale(d.closed)
|
|
|
|
return (
|
|
<g
|
|
key={d.label}
|
|
onMouseEnter={() => setHovered(i)}
|
|
onMouseLeave={() => setHovered(null)}
|
|
style={{ cursor: "pointer" }}
|
|
>
|
|
<rect
|
|
x={groupX}
|
|
y={0}
|
|
width={groupW}
|
|
height={chartH}
|
|
fill={isActive ? "hsl(var(--muted) / 0.5)" : "transparent"}
|
|
rx={6}
|
|
/>
|
|
|
|
<rect
|
|
x={groupX + groupW / 2 - barW - gap / 2}
|
|
y={yScale(d.leads)}
|
|
width={barW}
|
|
height={newH}
|
|
rx={4}
|
|
fill="url(#newLeadsGrad)"
|
|
filter={isActive ? "url(#shadowNew)" : undefined}
|
|
opacity={hovered !== null && !isActive ? 0.35 : 1}
|
|
style={{ transition: "opacity 0.15s ease" }}
|
|
/>
|
|
|
|
<rect
|
|
x={groupX + groupW / 2 + gap / 2}
|
|
y={yScale(d.closed)}
|
|
width={barW}
|
|
height={closedH}
|
|
rx={4}
|
|
fill="url(#closedGrad)"
|
|
filter={isActive ? "url(#shadowClosed)" : undefined}
|
|
opacity={hovered !== null && !isActive ? 0.35 : 1}
|
|
style={{ transition: "opacity 0.15s ease" }}
|
|
/>
|
|
|
|
<text
|
|
x={groupX + groupW / 2}
|
|
y={chartH + 26}
|
|
fill={isActive ? "hsl(var(--foreground))" : "hsl(var(--muted-foreground))"}
|
|
fontSize={12.5}
|
|
fontWeight={isActive ? 600 : 400}
|
|
textAnchor="middle"
|
|
>
|
|
{d.label}
|
|
</text>
|
|
</g>
|
|
)
|
|
})}
|
|
</g>
|
|
</svg>
|
|
|
|
{activeDatum && (
|
|
<div
|
|
className="pointer-events-none absolute top-0"
|
|
style={{
|
|
left: `${((active! + 0.5) / chartData.length) * 100}%`,
|
|
transform: active! > chartData.length - 2
|
|
? "translate(-100%, 0)"
|
|
: "translate(-12%, 0)",
|
|
transition: "left 0.15s ease",
|
|
}}
|
|
>
|
|
<div
|
|
className="min-w-[150px] rounded-xl border p-3 shadow-xl"
|
|
style={{
|
|
background: "hsl(var(--popover))",
|
|
borderColor: "hsl(var(--border))",
|
|
}}
|
|
>
|
|
<div className="mb-1.5 text-xs font-semibold" style={{ color: "hsl(var(--foreground))" }}>
|
|
{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">
|
|
<span className="inline-block h-2 w-2 rounded-sm" style={{ background: NEW_LEADS }} />
|
|
New Leads
|
|
</span>
|
|
<span className="font-semibold" style={{ color: "hsl(var(--foreground))" }}>{activeDatum.leads}</span>
|
|
</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">
|
|
<span className="inline-block h-2 w-2 rounded-sm" style={{ background: CLOSED }} />
|
|
Closed
|
|
</span>
|
|
<span className="font-semibold" style={{ color: "hsl(var(--foreground))" }}>{activeDatum.closed}</span>
|
|
</div>
|
|
<div
|
|
className="mt-1.5 border-t pt-1.5 text-[11px]"
|
|
style={{ borderColor: "hsl(var(--border))", color: "hsl(var(--muted-foreground))" }}
|
|
>
|
|
{activeDatum.leads > 0
|
|
? `${Math.round((activeDatum.closed / activeDatum.leads) * 100)}% close rate`
|
|
: "No leads"}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</motion.div>
|
|
)
|
|
}
|