feat: add system monitor component and API for performance metrics
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
This commit is contained in:
@@ -1,8 +1,10 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useMemo } from "react"
|
||||
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
|
||||
@@ -17,9 +19,35 @@ interface LeadsPerMonthChartProps {
|
||||
const NEW_LEADS = "#0d9488"
|
||||
const CLOSED = "#c9a96e"
|
||||
|
||||
export function LeadsPerMonthChart({ data }: LeadsPerMonthChartProps) {
|
||||
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 }
|
||||
@@ -27,38 +55,51 @@ export function LeadsPerMonthChart({ data }: LeadsPerMonthChartProps) {
|
||||
const chartH = height - padding.top - padding.bottom
|
||||
|
||||
const maxVal = useMemo(() => {
|
||||
if (data.length === 0) return 1
|
||||
const max = Math.max(...data.map((d) => Math.max(d.leads, d.closed)))
|
||||
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)
|
||||
}, [data])
|
||||
}, [chartData])
|
||||
|
||||
const ticks = useMemo(() => {
|
||||
const steps = 4
|
||||
return Array.from({ length: steps + 1 }, (_, i) => Math.round((maxVal / steps) * i))
|
||||
}, [maxVal])
|
||||
|
||||
const groupW = chartW / data.length
|
||||
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 ? data[active] : null
|
||||
const activeDatum = active !== null ? chartData[active] : null
|
||||
|
||||
const totalNew = data.reduce((s, d) => s + d.leads, 0)
|
||||
const totalClosed = data.reduce((s, d) => s + d.closed, 0)
|
||||
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
|
||||
|
||||
if (data.length === 0) {
|
||||
const currentYear = new Date().getFullYear()
|
||||
|
||||
if (chartData.length === 0) {
|
||||
return (
|
||||
<Card className="h-full">
|
||||
<CardHeader>
|
||||
<CardTitle>Leads Per Month</CardTitle>
|
||||
<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 this period
|
||||
No data for {year}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -90,6 +131,15 @@ export function LeadsPerMonthChart({ data }: LeadsPerMonthChartProps) {
|
||||
<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>
|
||||
@@ -145,7 +195,7 @@ export function LeadsPerMonthChart({ data }: LeadsPerMonthChartProps) {
|
||||
))}
|
||||
|
||||
{/* Bars */}
|
||||
{data.map((d, i) => {
|
||||
{chartData.map((d, i) => {
|
||||
const groupX = i * groupW
|
||||
const isActive = active === i
|
||||
const newH = chartH - yScale(d.leads)
|
||||
@@ -158,7 +208,6 @@ export function LeadsPerMonthChart({ data }: LeadsPerMonthChartProps) {
|
||||
onMouseLeave={() => setHovered(null)}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
{/* Hover highlight band */}
|
||||
<rect
|
||||
x={groupX}
|
||||
y={0}
|
||||
@@ -168,7 +217,6 @@ export function LeadsPerMonthChart({ data }: LeadsPerMonthChartProps) {
|
||||
rx={6}
|
||||
/>
|
||||
|
||||
{/* New Leads bar */}
|
||||
<rect
|
||||
x={groupX + groupW / 2 - barW - gap / 2}
|
||||
y={yScale(d.leads)}
|
||||
@@ -181,7 +229,6 @@ export function LeadsPerMonthChart({ data }: LeadsPerMonthChartProps) {
|
||||
style={{ transition: "opacity 0.15s ease" }}
|
||||
/>
|
||||
|
||||
{/* Closed bar */}
|
||||
<rect
|
||||
x={groupX + groupW / 2 + gap / 2}
|
||||
y={yScale(d.closed)}
|
||||
@@ -194,7 +241,6 @@ export function LeadsPerMonthChart({ data }: LeadsPerMonthChartProps) {
|
||||
style={{ transition: "opacity 0.15s ease" }}
|
||||
/>
|
||||
|
||||
{/* Month label */}
|
||||
<text
|
||||
x={groupX + groupW / 2}
|
||||
y={chartH + 26}
|
||||
@@ -211,13 +257,12 @@ export function LeadsPerMonthChart({ data }: LeadsPerMonthChartProps) {
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
{/* Tooltip */}
|
||||
{activeDatum && (
|
||||
<div
|
||||
className="pointer-events-none absolute top-0"
|
||||
style={{
|
||||
left: `${((active! + 0.5) / data.length) * 100}%`,
|
||||
transform: active! > data.length - 2
|
||||
left: `${((active! + 0.5) / chartData.length) * 100}%`,
|
||||
transform: active! > chartData.length - 2
|
||||
? "translate(-100%, 0)"
|
||||
: "translate(-12%, 0)",
|
||||
transition: "left 0.15s ease",
|
||||
|
||||
@@ -5,8 +5,8 @@ import { Skeleton } from "@/components/ui/skeleton"
|
||||
|
||||
export function StatCardSkeleton() {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<Card className="h-full">
|
||||
<CardContent className="p-6 flex flex-col">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
|
||||
@@ -10,62 +10,47 @@ interface StatCardProps {
|
||||
title: string
|
||||
value: string | number
|
||||
icon: LucideIcon
|
||||
variant?: "default" | "blue" | "amber" | "purple" | "emerald" | "zinc"
|
||||
description?: string
|
||||
index?: number
|
||||
trend?: { pct: number; up: boolean }
|
||||
sparklineField?: "total" | "open" | "contacted" | "pending" | "closed" | "ignored"
|
||||
monthlyBreakdown?: { label: string; total: number; open: number; contacted: number; pending: number; closed: number; ignored: number }[]
|
||||
conversionRate?: number
|
||||
}
|
||||
|
||||
const variantStyles = {
|
||||
default: { bg: "bg-muted", text: "text-foreground" },
|
||||
blue: { bg: "bg-teal-500/10", text: "text-teal-600 dark:text-teal-400" },
|
||||
amber: { bg: "bg-amber-500/10", text: "text-amber-600 dark:text-amber-400" },
|
||||
purple: { bg: "bg-purple-500/10", text: "text-purple-600 dark:text-purple-400" },
|
||||
emerald: { bg: "bg-emerald-500/10", text: "text-emerald-600 dark:text-emerald-400" },
|
||||
zinc: { bg: "bg-zinc-500/10", text: "text-zinc-600 dark:text-zinc-400" },
|
||||
const cardColors: Record<string, { bg: string; text: string; glow: string; accent: string }> = {
|
||||
"Total Leads": { bg: "bg-cyan-500/10", text: "text-cyan-600 dark:text-cyan-400", glow: "via-[rgba(6,182,212,0.25)]", accent: "#06b6d4" },
|
||||
"Open Leads": { bg: "bg-blue-500/10", text: "text-blue-600 dark:text-blue-400", glow: "via-[rgba(59,130,246,0.25)]", accent: "#3b82f6" },
|
||||
"Contacted": { bg: "bg-amber-500/10", text: "text-amber-600 dark:text-amber-400", glow: "via-[rgba(245,158,11,0.25)]", accent: "#f59e0b" },
|
||||
"Pending": { bg: "bg-violet-500/10", text: "text-violet-600 dark:text-violet-400", glow: "via-[rgba(139,92,246,0.25)]", accent: "#8b5cf6" },
|
||||
"Closed": { bg: "bg-yellow-500/10", text: "text-yellow-600 dark:text-yellow-400", glow: "via-[rgba(234,179,8,0.25)]", accent: "#eab308" },
|
||||
"Conversion Rate": { bg: "bg-rose-500/10", text: "text-rose-600 dark:text-rose-400", glow: "via-[rgba(244,63,94,0.25)]", accent: "#f43f5e" },
|
||||
}
|
||||
|
||||
const cardGradients: Record<string, string> = {
|
||||
"Total Leads": "from-[#0d9488] to-[#5eead4]",
|
||||
"Open Leads": "from-[#14b8a6] to-[#5eead4]",
|
||||
"Contacted": "from-[#c9a96e] to-[#e8d5a3]",
|
||||
"Pending": "from-[#94a3b8] to-[#cbd5e1]",
|
||||
"Closed": "from-[#c9a96e] to-[#e8d5a3]",
|
||||
"Conversion Rate": "from-[#f43f5e] to-[#fb7185]",
|
||||
function computeGoal(max: number): number {
|
||||
if (max <= 0) return 100
|
||||
return Math.ceil(max / 100) * 100 || 100
|
||||
}
|
||||
|
||||
const trendBadges: Record<string, { label: string; up: boolean }> = {
|
||||
"Total Leads": { label: "12%", up: true },
|
||||
"Open Leads": { label: "8%", up: true },
|
||||
"Contacted": { label: "5%", up: true },
|
||||
"Pending": { label: "3%", up: false },
|
||||
"Closed": { label: "15%", up: true },
|
||||
"Conversion Rate": { label: "2%", up: true },
|
||||
function smoothPath(points: { x: number; y: number }[]): string {
|
||||
if (points.length === 0) return ""
|
||||
if (points.length === 1) return `M${points[0].x.toFixed(1)},${points[0].y.toFixed(1)}`
|
||||
let d = `M${points[0].x.toFixed(1)},${points[0].y.toFixed(1)}`
|
||||
for (let i = 1; i < points.length - 1; i++) {
|
||||
const p0 = points[i - 1]
|
||||
const p1 = points[i]
|
||||
const p2 = points[i + 1]
|
||||
const cp1x = p1.x - (p2.x - p0.x) * 0.15
|
||||
const cp1y = p1.y - (p2.y - p0.y) * 0.15
|
||||
const cp2x = p1.x + (p2.x - p0.x) * 0.15
|
||||
const cp2y = p1.y + (p2.y - p0.y) * 0.15
|
||||
d += `C${cp1x.toFixed(1)},${cp1y.toFixed(1)} ${cp2x.toFixed(1)},${cp2y.toFixed(1)} ${p2.x.toFixed(1)},${p2.y.toFixed(1)}`
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
const sparklines: Record<string, string> = {
|
||||
"Total Leads": "M0,28 L10,22 L20,18 L30,20 L40,12 L50,8 L60,6",
|
||||
"Open Leads": "M0,28 L10,24 L20,26 L30,20 L40,22 L50,18 L60,14",
|
||||
"Contacted": "M0,28 L10,20 L20,16 L30,18 L40,10 L50,6 L60,4",
|
||||
"Pending": "M0,28 L10,26 L20,24 L30,22 L40,20 L50,18 L60,16",
|
||||
"Closed": "M0,28 L10,24 L20,18 L30,14 L40,10 L50,6 L60,2",
|
||||
"Conversion Rate": "M0,28 L10,22 L20,20 L30,16 L40,12 L50,8 L60,4",
|
||||
}
|
||||
|
||||
const sparklineColors: Record<string, string> = {
|
||||
"Total Leads": "#0d9488",
|
||||
"Open Leads": "#14b8a6",
|
||||
"Contacted": "#c9a96e",
|
||||
"Pending": "#94a3b8",
|
||||
"Closed": "#c9a96e",
|
||||
"Conversion Rate": "#f43f5e",
|
||||
}
|
||||
|
||||
export function StatCard({ title, value, icon: Icon, variant = "default", description, index = 0 }: StatCardProps) {
|
||||
const style = variantStyles[variant]
|
||||
const gradient = cardGradients[title] ?? "from-[#0d9488] to-[#5eead4]"
|
||||
const sparkPath = sparklines[title] ?? "M0,28 L10,22 L20,18 L30,20 L40,12 L50,8 L60,6"
|
||||
const sparkColor = sparklineColors[title] ?? "#0d9488"
|
||||
const badge = trendBadges[title]
|
||||
export function StatCard({ title, value, icon: Icon, description, index = 0, trend, sparklineField, monthlyBreakdown, conversionRate }: StatCardProps) {
|
||||
const color = cardColors[title] ?? cardColors["Total Leads"]
|
||||
|
||||
const isNumeric = typeof value === "number"
|
||||
const [display, setDisplay] = useState(0)
|
||||
@@ -89,6 +74,39 @@ export function StatCard({ title, value, icon: Icon, variant = "default", descri
|
||||
return () => clearInterval(timer)
|
||||
}, [target, isNumeric])
|
||||
|
||||
function buildSparklineSvg(): { path: string; area: string; goal: number; gridLines: { y: number }[] } {
|
||||
if (!monthlyBreakdown || !sparklineField) return { path: "", area: "", goal: 100, gridLines: [] }
|
||||
const values = monthlyBreakdown.map((m) => m[sparklineField]).reverse()
|
||||
const max = Math.max(...values, 0)
|
||||
const goal = computeGoal(max)
|
||||
const dataRange = Math.max(max, 1)
|
||||
const range = goal <= 10 ? Math.max(dataRange * 2, 10) : Math.min(goal, Math.max(dataRange * 3, 10))
|
||||
const w = values.length - 1 || 1
|
||||
const pad = 4
|
||||
const graphW = 60 - pad * 2
|
||||
const graphH = 28
|
||||
|
||||
const points = values.map((v, i) => ({
|
||||
x: pad + (i / w) * graphW,
|
||||
y: graphH - (v / range) * (graphH - 3) - 1,
|
||||
}))
|
||||
|
||||
const smooth = smoothPath(points)
|
||||
const area = points.length > 0
|
||||
? `${smooth} L${points[points.length - 1].x.toFixed(1)},${graphH} L${points[0].x.toFixed(1)},${graphH} Z`
|
||||
: ""
|
||||
|
||||
const steps = 4
|
||||
const gridLines = Array.from({ length: steps - 1 }, (_, i) => ({
|
||||
y: graphH - ((i + 1) / steps) * (graphH - 3) - 1,
|
||||
}))
|
||||
|
||||
return { path: smooth, area, goal, gridLines }
|
||||
}
|
||||
|
||||
const { path: sparkPath, area: sparkArea, goal, gridLines } = buildSparklineSvg()
|
||||
const sparkColor = color.accent
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
@@ -96,8 +114,8 @@ export function StatCard({ title, value, icon: Icon, variant = "default", descri
|
||||
transition={{ duration: 0.3, delay: index * 0.05 }}
|
||||
className="relative"
|
||||
>
|
||||
<Card className="group hover:shadow-md transition-all duration-200">
|
||||
<CardContent className="p-6">
|
||||
<Card className="group h-full hover:shadow-md transition-all duration-200">
|
||||
<CardContent className="p-6 flex flex-col">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-muted-foreground">{title}</p>
|
||||
@@ -107,49 +125,71 @@ export function StatCard({ title, value, icon: Icon, variant = "default", descri
|
||||
{description && (
|
||||
<p className="text-xs text-muted-foreground">{description}</p>
|
||||
)}
|
||||
{badge && (
|
||||
{trend && (
|
||||
<span className={cn(
|
||||
"inline-flex items-center gap-0.5 text-xs font-semibold",
|
||||
badge.up ? "text-emerald-500" : "text-rose-500"
|
||||
trend.up ? "text-emerald-500" : "text-rose-500"
|
||||
)}>
|
||||
{badge.up ? "↑" : "↓"} {badge.label}
|
||||
{trend.up ? "↑" : "↓"} {trend.pct}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className={cn("flex h-12 w-12 items-center justify-center rounded-xl shrink-0", style.bg)}>
|
||||
<Icon className={cn("h-6 w-6", style.text)} />
|
||||
<div className={cn("flex h-12 w-12 items-center justify-center rounded-xl shrink-0", color.bg)}>
|
||||
<Icon className={cn("h-6 w-6", color.text)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<svg width="60" height="28" viewBox="0 0 60 28" className="opacity-60 group-hover:opacity-100 transition-opacity duration-200">
|
||||
<defs>
|
||||
<linearGradient id={`spark-fill-${title.replace(/\s/g, "")}`} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={sparkColor} stopOpacity="0.25" />
|
||||
<stop offset="100%" stopColor={sparkColor} stopOpacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d={`${sparkPath} L60,28 L0,28 Z`} fill={`url(#spark-fill-${title.replace(/\s/g, "")})`} />
|
||||
<path d={sparkPath} fill="none" stroke={sparkColor} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</div>
|
||||
{sparkPath && (
|
||||
<div className="mt-auto relative">
|
||||
<svg width="60" height="28" viewBox="0 0 60 28" className="opacity-60 group-hover:opacity-100 transition-opacity duration-200">
|
||||
<defs>
|
||||
<linearGradient id={`spark-fill-${title.replace(/\s/g, "")}`} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={sparkColor} stopOpacity="0.2" />
|
||||
<stop offset="100%" stopColor={sparkColor} stopOpacity="0" />
|
||||
</linearGradient>
|
||||
<filter id={`glow-${title.replace(/\s/g, "")}`}>
|
||||
<feGaussianBlur stdDeviation="2" result="blur" />
|
||||
<feMerge>
|
||||
<feMergeNode in="blur" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
{gridLines.map((gl, i) => (
|
||||
<line key={i} x1="0" y1={gl.y} x2="60" y2={gl.y} stroke="currentColor" strokeOpacity="0.08" strokeWidth="1" strokeDasharray="2,2" />
|
||||
))}
|
||||
<path d={sparkArea} fill={`url(#spark-fill-${title.replace(/\s/g, "")})`} />
|
||||
<path
|
||||
d={sparkPath}
|
||||
fill="none"
|
||||
stroke={sparkColor}
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="animate-pulse"
|
||||
filter={`url(#glow-${title.replace(/\s/g, "")})`}
|
||||
/>
|
||||
</svg>
|
||||
<span className="absolute -bottom-0.5 right-0 text-[9px] font-medium text-muted-foreground/60">
|
||||
/{goal}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{title === "Conversion Rate" && (
|
||||
{title === "Conversion Rate" && conversionRate !== undefined && (
|
||||
<div className="mt-3">
|
||||
<div className="w-full h-1.5 bg-muted rounded-full overflow-hidden">
|
||||
<div className="h-full rounded-full bg-gradient-to-r from-[#0d9488] to-[#5eead4]" style={{ width: "22%" }} />
|
||||
<div className="h-full rounded-full bg-gradient-to-r from-[#f43f5e] to-[#fda4af]" style={{ width: `${Math.min(conversionRate, 100)}%` }} />
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">22% conversion rate</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">{conversionRate}% conversion rate</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
{(title === "Closed" || title === "Conversion Rate") && (
|
||||
<div className={cn(
|
||||
"absolute bottom-0 left-0 right-0 h-1 bg-gradient-to-r from-transparent to-transparent",
|
||||
title === "Closed" ? "via-[rgba(201,169,110,0.2)]" : "via-[rgba(244,63,94,0.2)]"
|
||||
)} />
|
||||
)}
|
||||
<div className={cn(
|
||||
"absolute bottom-0 left-0 right-0 h-1 bg-gradient-to-r from-transparent to-transparent",
|
||||
color.glow
|
||||
)} />
|
||||
</Card>
|
||||
</motion.div>
|
||||
)
|
||||
|
||||
@@ -5,6 +5,7 @@ import { usePathname } from "next/navigation"
|
||||
import { motion, AnimatePresence } from "framer-motion"
|
||||
import { Sidebar } from "./sidebar"
|
||||
import { Topbar } from "./topbar"
|
||||
import { SystemMonitor } from "./system-monitor"
|
||||
|
||||
interface AppShellProps {
|
||||
children: React.ReactNode
|
||||
@@ -34,6 +35,7 @@ export function AppShell({ children }: AppShellProps) {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<SystemMonitor />
|
||||
<Sidebar
|
||||
collapsed={sidebarCollapsed}
|
||||
onToggle={toggleSidebar}
|
||||
|
||||
@@ -104,9 +104,7 @@ export function Sidebar({ collapsed, onToggle, mobileOpen, onMobileClose }: Side
|
||||
>
|
||||
<item.icon className="h-5 w-5" />
|
||||
{item.label === "Chats" && unreadChatCount > 0 && (
|
||||
<span className="absolute -right-0.5 -top-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-destructive px-1 text-[10px] font-medium text-destructive-foreground">
|
||||
{unreadChatCount}
|
||||
</span>
|
||||
<span className="absolute -right-0.5 -top-0.5 flex h-3 w-3 rounded-full bg-red-500 animate-pulse shadow-[0_0_10px_#ef4444]" />
|
||||
)}
|
||||
</Link>
|
||||
</TooltipTrigger>
|
||||
@@ -126,7 +124,12 @@ export function Sidebar({ collapsed, onToggle, mobileOpen, onMobileClose }: Side
|
||||
: "text-sidebar-foreground/60 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
|
||||
)}
|
||||
>
|
||||
<item.icon className="h-5 w-5 shrink-0" />
|
||||
<div className="relative shrink-0">
|
||||
<item.icon className="h-5 w-5" />
|
||||
{item.label === "Chats" && unreadChatCount > 0 && (
|
||||
<span className="absolute -right-1 -top-1 flex h-2.5 w-2.5 rounded-full bg-red-500 animate-pulse shadow-[0_0_8px_#ef4444]" />
|
||||
)}
|
||||
</div>
|
||||
<motion.span
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
|
||||
const RAM_LIMIT_MB = 8192
|
||||
const CPU_LIMIT_PCT = 400 // 4 cores * 100%
|
||||
|
||||
export function SystemMonitor() {
|
||||
const [rssMB, setRssMB] = useState(0)
|
||||
const [cpuPct, setCpuPct] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
const fetchStats = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/system/monitor")
|
||||
if (!res.ok) return
|
||||
const data = await res.json()
|
||||
setRssMB(data.rssMB)
|
||||
setCpuPct(data.cpuPct)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
fetchStats()
|
||||
const interval = setInterval(fetchStats, 3000)
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
const ramOver = rssMB > RAM_LIMIT_MB
|
||||
const cpuOver = cpuPct > CPU_LIMIT_PCT
|
||||
|
||||
return (
|
||||
<div className="fixed top-0 left-0 z-[9999] flex items-center gap-3 px-3 py-1 text-[11px] font-mono bg-black/80 rounded-br-lg select-none">
|
||||
<span className={ramOver ? "text-red-400" : "text-green-400"}>
|
||||
RAM: {rssMB}MB / 8192MB
|
||||
</span>
|
||||
<span className={cpuOver ? "text-red-400" : "text-green-400"}>
|
||||
CPU: {cpuPct}%
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { useUser } from "@/providers/user-provider";
|
||||
import { useNotifications } from "@/providers/notification-provider";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
|
||||
@@ -15,14 +15,21 @@ export function NoteForm({ leadId }: NoteFormProps) {
|
||||
const [note, setNote] = useState("")
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
const handleSubmit = () => {
|
||||
const handleSubmit = async () => {
|
||||
if (!note.trim()) return
|
||||
setSubmitting(true)
|
||||
setTimeout(() => {
|
||||
console.log("Note added:", { leadId, note })
|
||||
try {
|
||||
await fetch(`/api/leads/${leadId}/notes`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ content: note }),
|
||||
})
|
||||
setNote("")
|
||||
setSubmitting(false)
|
||||
}, 500)
|
||||
window.location.reload()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
setSubmitting(false)
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user