diff --git a/eslint.config.mjs b/eslint.config.mjs index 199e80e..05e726d 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,19 +1,18 @@ -import { dirname } from "path"; -import { fileURLToPath } from "url"; -import { FlatCompat } from "@eslint/eslintrc"; +import { defineConfig, globalIgnores } from "eslint/config"; +import nextVitals from "eslint-config-next/core-web-vitals"; +import nextTs from "eslint-config-next/typescript"; -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); - -const compat = new FlatCompat({ - baseDirectory: __dirname, -}); - -const eslintConfig = [ - ...compat.extends("next/core-web-vitals", "next/typescript"), - { - ignores: [".next/**", "out/**", "build/**", "next-env.d.ts"], - }, -]; +const eslintConfig = defineConfig([ + ...nextVitals, + ...nextTs, + // Override default ignores of eslint-config-next. + globalIgnores([ + // Default ignores of eslint-config-next: + ".next/**", + "out/**", + "build/**", + "next-env.d.ts", + ]), +]); export default eslintConfig; diff --git a/src/app/(dashboard)/dashboard/page.tsx b/src/app/(dashboard)/dashboard/page.tsx index 6b36c95..5de5930 100644 --- a/src/app/(dashboard)/dashboard/page.tsx +++ b/src/app/(dashboard)/dashboard/page.tsx @@ -62,6 +62,8 @@ export default function DashboardPage() { +

Pipeline Overview

+
{stats ? statCards.map((card, i) => ( @@ -71,6 +73,8 @@ export default function DashboardPage() { }
+

Analytics

+
diff --git a/src/app/(dashboard)/users/page.tsx b/src/app/(dashboard)/users/page.tsx index 1d2ee14..c86b26e 100644 --- a/src/app/(dashboard)/users/page.tsx +++ b/src/app/(dashboard)/users/page.tsx @@ -1,29 +1,16 @@ -"use client" +"use client"; -import { useState, useMemo } 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 { users } from "@/data/users" -import { useUser } from "@/providers/user-provider" -import { Plus, Search, Users as UsersIcon } from "lucide-react" +import { useState } 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 { users } from "@/data/users"; +import { Plus, Users as UsersIcon } from "lucide-react"; export default function UsersPage() { - const { user } = useUser() - const [createOpen, setCreateOpen] = useState(false) - const [search, setSearch] = useState("") - - const filteredUsers = useMemo(() => { - if (!search) return users - const q = search.toLowerCase() - return users.filter( - (u) => - u.name.toLowerCase().includes(q) || - u.email.toLowerCase().includes(q) - ) - }, [search]) + const { user } = useUser(); + const [createOpen, setCreateOpen] = useState(false); return (
@@ -31,59 +18,28 @@ export default function UsersPage() { title="Users" description="Manage team members and their roles" > - {user?.role === "super_admin" && ( - - )} +
-
-
-
- -
-
-

{users.length}

-

Total Users

+ {stats.map((s) => ( +
+
+
+ +
+
+

{s.value}

+

{s.label}

+
-
-
-
-
- -
-
-

{users.filter((u) => u.active).length}

-

Active

-
-
-
-
-
-
- -
-
-

{users.filter((u) => u.role === "admin").length}

-

Admins

-
-
-
-
-
-
- -
-
-

{users.filter((u) => u.role === "sales").length}

-

Sales

-
-
-
+ ))}
@@ -97,10 +53,14 @@ export default function UsersPage() {
- +
- +
- ) + ); } diff --git a/src/app/globals.css b/src/app/globals.css index 5fe372e..5615ace 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -2,77 +2,73 @@ @tailwind components; @tailwind utilities; -@layer base { - :root { - --background: 210 40% 98%; - --foreground: 222.2 84% 4.9%; - --card: 0 0% 100%; - --card-foreground: 222.2 84% 4.9%; - --popover: 0 0% 100%; - --popover-foreground: 222.2 84% 4.9%; - --primary: 221.2 83.2% 53.3%; - --primary-foreground: 210 40% 98%; - --secondary: 210 40% 96.1%; - --secondary-foreground: 222.2 47.4% 11.2%; - --muted: 210 40% 96.1%; - --muted-foreground: 215.4 16.3% 46.9%; - --accent: 210 40% 96.1%; - --accent-foreground: 222.2 47.4% 11.2%; - --destructive: 0 84.2% 60.2%; - --destructive-foreground: 210 40% 98%; - --border: 214.3 31.8% 91.4%; - --input: 214.3 31.8% 91.4%; - --ring: 221.2 83.2% 53.3%; - --sidebar: 222.2 84% 4.9%; - --sidebar-foreground: 210 40% 98%; - --sidebar-primary: 217.2 91.2% 59.8%; - --sidebar-primary-foreground: 0 0% 100%; - --sidebar-accent: 217.2 32.6% 17.5%; - --sidebar-accent-foreground: 210 40% 98%; - --sidebar-border: 217.2 32.6% 17.5%; - --sidebar-ring: 224.3 76.3% 48%; - --radius: 0.5rem; - } +:root { + --background: 210 40% 98%; + --foreground: 222.2 84% 4.9%; + --card: 0 0% 100%; + --card-foreground: 222.2 84% 4.9%; + --popover: 0 0% 100%; + --popover-foreground: 222.2 84% 4.9%; + --primary: 221.2 83.2% 53.3%; + --primary-foreground: 210 40% 98%; + --secondary: 210 40% 96.1%; + --secondary-foreground: 222.2 47.4% 11.2%; + --muted: 210 40% 96.1%; + --muted-foreground: 215.4 16.3% 46.9%; + --accent: 210 40% 96.1%; + --accent-foreground: 222.2 47.4% 11.2%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 210 40% 98%; + --border: 214.3 31.8% 91.4%; + --input: 214.3 31.8% 91.4%; + --ring: 221.2 83.2% 53.3%; + --sidebar: 222.2 84% 4.9%; + --sidebar-foreground: 210 40% 98%; + --sidebar-primary: 217.2 91.2% 59.8%; + --sidebar-primary-foreground: 0 0% 100%; + --sidebar-accent: 217.2 32.6% 17.5%; + --sidebar-accent-foreground: 210 40% 98%; + --sidebar-border: 217.2 32.6% 17.5%; + --sidebar-ring: 224.3 76.3% 48%; + --radius: 0.5rem; +} - .dark { - --background: 222.2 84% 4.9%; - --foreground: 210 40% 98%; - --card: 222.2 84% 4.9%; - --card-foreground: 210 40% 98%; - --popover: 222.2 84% 4.9%; - --popover-foreground: 210 40% 98%; - --primary: 217.2 91.2% 59.8%; - --primary-foreground: 222.2 47.4% 11.2%; - --secondary: 217.2 32.6% 17.5%; - --secondary-foreground: 210 40% 98%; - --muted: 217.2 32.6% 17.5%; - --muted-foreground: 215 20.2% 65.1%; - --accent: 217.2 32.6% 17.5%; - --accent-foreground: 210 40% 98%; - --destructive: 0 62.8% 30.6%; - --destructive-foreground: 210 40% 98%; - --border: 217.2 32.6% 17.5%; - --input: 217.2 32.6% 17.5%; - --ring: 224.3 76.3% 48%; - --sidebar: 222.2 84% 4.9%; - --sidebar-foreground: 210 40% 98%; - --sidebar-primary: 217.2 91.2% 59.8%; - --sidebar-primary-foreground: 0 0% 100%; - --sidebar-accent: 217.2 32.6% 17.5%; - --sidebar-accent-foreground: 210 40% 98%; - --sidebar-border: 217.2 32.6% 17.5%; - --sidebar-ring: 224.3 76.3% 48%; - } +.dark { + --background: 222.2 84% 4.9%; + --foreground: 210 40% 98%; + --card: 222.2 84% 4.9%; + --card-foreground: 210 40% 98%; + --popover: 222.2 84% 4.9%; + --popover-foreground: 210 40% 98%; + --primary: 217.2 91.2% 59.8%; + --primary-foreground: 222.2 47.4% 11.2%; + --secondary: 217.2 32.6% 17.5%; + --secondary-foreground: 210 40% 98%; + --muted: 217.2 32.6% 17.5%; + --muted-foreground: 215 20.2% 65.1%; + --accent: 217.2 32.6% 17.5%; + --accent-foreground: 210 40% 98%; + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 210 40% 98%; + --border: 217.2 32.6% 17.5%; + --input: 217.2 32.6% 17.5%; + --ring: 224.3 76.3% 48%; + --sidebar: 222.2 84% 4.9%; + --sidebar-foreground: 210 40% 98%; + --sidebar-primary: 217.2 91.2% 59.8%; + --sidebar-primary-foreground: 0 0% 100%; + --sidebar-accent: 217.2 32.6% 17.5%; + --sidebar-accent-foreground: 210 40% 98%; + --sidebar-border: 217.2 32.6% 17.5%; + --sidebar-ring: 224.3 76.3% 48%; +} * { @apply border-border; } - - body { @apply bg-background text-foreground; font-family: var(--font-inter), ui-sans-serif, system-ui, sans-serif; } - } diff --git a/src/app/layout.tsx b/src/app/layout.tsx new file mode 100644 index 0000000..b333a78 --- /dev/null +++ b/src/app/layout.tsx @@ -0,0 +1,35 @@ +import type { Metadata } from "next" +import { Inter } from "next/font/google" +import { ThemeProvider } from "@/providers/theme-provider" +import { Toaster } from "@/components/ui/sonner" +import "./globals.css" + +const inter = Inter({ + variable: "--font-inter", + subsets: ["latin"], +}) + +export const metadata: Metadata = { + title: "Coast IT - CRM", + description: "Customer Relationship Management System", + icons: { + icon: "/logo/CompanyMiniLogo.png", + }, +} + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode +}>) { + return ( + + + + {children} + + + + + ) +} diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index 2375af0..c8a58fc 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -1,13 +1,14 @@ "use client" -import { Suspense, useState } from "react" -import { useRouter, useSearchParams } from "next/navigation" +import { useState } from "react" +import { useRouter } from "next/navigation" import { motion } from "framer-motion" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" +import { Checkbox } from "@/components/ui/checkbox" import { COMPANY_NAME } from "@/lib/constants" -import { Eye, EyeOff, Loader2, AlertCircle } from "lucide-react" +import { Eye, EyeOff, Loader2 } from "lucide-react" function LoginForm() { const router = useRouter() @@ -16,191 +17,409 @@ function LoginForm() { const [password, setPassword] = useState("") const [showPassword, setShowPassword] = useState(false) const [loading, setLoading] = useState(false) - const [error, setError] = useState("") + const [remember, setRemember] = useState(false) const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() setError("") setLoading(true) - try { - const res = await fetch("/api/auth/login", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ email, password }), - }) + await new Promise((resolve) => setTimeout(resolve, 1200)) - const data = await res.json() - - if (!res.ok) { - setError(data.error || "Authentication failed.") - setLoading(false) - return - } - - const redirect = searchParams.get("redirect") || "/dashboard" - router.push(redirect) - } catch { - setError("Unable to connect to authentication service. Please ensure the database is running.") - setLoading(false) - } + router.push("/dashboard") } return ( <> - {error && ( -
- - {error} -
- )} + -
-
- - setEmail(e.target.value)} - required - autoComplete="email" - /> -
- -
- -
- setPassword(e.target.value)} - required - autoComplete="current-password" +
+
+
+ {COMPANY_NAME} - +
+ +
+
+

+ Your Agency's{" "} + Growth{" "} + Starts Here +

+

+ Manage leads, track conversions, and grow your web development business with our CRM. +

+
+
+
{counts.leads.toLocaleString()}
+
leads tracked
+
+
+
{counts.conversion}%
+
conversion lift
+
+
+
{counts.users}
+
active users
+
+
+
+
+ +
+

+ “This CRM transformed how we manage our sales pipeline. We've seen a 40% increase in lead conversion.” +

+

+ Marcus Johnson, Sales Lead at Coast IT +

+
+ + +
+ +
+ +
+ +
+ +
+

+ Welcome back +

+

+ Sign in to your account to continue +

+ + +
+ + setEmail(e.target.value)} + required + autoComplete="email" + /> +
+ +
+
+ + +
+
+ setPassword(e.target.value)} + required + autoComplete="current-password" + /> + +
+
+ +
+ setRemember(checked as boolean)} + /> + +
+ + + + +

+ By signing in, you agree to our{" "} + {" "} + and{" "} + +

- - - +
) } - -export default function LoginPage() { - - return ( -
- {/* Left - Brand panel */} -
-
- -
-
- {COMPANY_NAME} - {COMPANY_NAME} -
-
- -
- - Your Agency's Growth Starts Here - - - Manage leads, track conversions, and grow your web development business with our CRM. - - - -

- “This CRM transformed how we manage our sales pipeline. We've seen a 40% increase in lead conversion.” -

-
-
-
-

Marcus Johnson

-

Sales Lead, Coast IT

-
-
- -
- -
- © 2026 {COMPANY_NAME}. All rights reserved. -
-
- - {/* Right - Login form */} -
- - {/* Mobile logo */} -
- {COMPANY_NAME} - {COMPANY_NAME} -
- -
-

Welcome back

-

- Sign in to your account to continue -

-
- - Loading...
}> - - - -

- By signing in, you agree to our{" "} - {" "} - and{" "} - -

- -
-
- ) -} diff --git a/src/app/page.tsx b/src/app/page.tsx index 9c96519..3ea71e0 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,5 +1,5 @@ import { redirect } from "next/navigation" export default function RootPage() { - redirect("/login") + redirect("/dashboard") } diff --git a/src/components/dashboard/leads-per-month-chart.tsx b/src/components/dashboard/leads-per-month-chart.tsx index c758339..edd639a 100644 --- a/src/components/dashboard/leads-per-month-chart.tsx +++ b/src/components/dashboard/leads-per-month-chart.tsx @@ -14,9 +14,8 @@ interface LeadsPerMonthChartProps { data: IntervalData[] } -const BAR_GRADIENT_TOP = "#3B6AB8" -const NEW_LEADS = "#1e3c72" -const CLOSED = "#457fca" +const NEW_LEADS = "#0d9488" +const CLOSED = "#c9a96e" export function LeadsPerMonthChart({ data }: LeadsPerMonthChartProps) { const [hovered, setHovered] = useState(null) @@ -104,14 +103,14 @@ export function LeadsPerMonthChart({ data }: LeadsPerMonthChartProps) { > - + - + - + @@ -177,7 +176,7 @@ export function LeadsPerMonthChart({ data }: LeadsPerMonthChartProps) { height={newH} rx={4} fill="url(#newLeadsGrad)" - filter={isActive ? "url(#shadowBlue)" : undefined} + filter={isActive ? "url(#shadowNew)" : undefined} opacity={hovered !== null && !isActive ? 0.35 : 1} style={{ transition: "opacity 0.15s ease" }} /> @@ -264,4 +263,4 @@ export function LeadsPerMonthChart({ data }: LeadsPerMonthChartProps) { ) -} \ No newline at end of file +} diff --git a/src/components/dashboard/stat-card.tsx b/src/components/dashboard/stat-card.tsx index 2854439..01b44fc 100644 --- a/src/components/dashboard/stat-card.tsx +++ b/src/components/dashboard/stat-card.tsx @@ -1,5 +1,6 @@ "use client" +import { useEffect, useState } from "react" import { motion } from "framer-motion" import { cn } from "@/lib/utils" import { Card, CardContent } from "@/components/ui/card" @@ -16,37 +17,139 @@ interface StatCardProps { const variantStyles = { default: { bg: "bg-muted", text: "text-foreground" }, - blue: { bg: "bg-blue-500/10", text: "text-blue-600 dark:text-blue-400" }, + 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 cardGradients: Record = { + "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]", +} + +const trendBadges: Record = { + "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 }, +} + +const sparklines: Record = { + "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 = { + "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] + + const isNumeric = typeof value === "number" + const [display, setDisplay] = useState(0) + const target = typeof value === "number" ? value : 0 + + useEffect(() => { + if (!isNumeric) return + const duration = 1000 + const steps = 40 + const increment = target / steps + let current = 0 + const timer = setInterval(() => { + current += increment + if (current >= target) { + setDisplay(target) + clearInterval(timer) + } else { + setDisplay(Math.floor(current)) + } + }, duration / steps) + return () => clearInterval(timer) + }, [target, isNumeric]) return ( - +

{title}

-

{value}

+

+ {isNumeric ? display : value} +

{description && (

{description}

)} + {badge && ( + + {badge.up ? "↑" : "↓"} {badge.label} + + )}
-
+
+ +
+ + + + + + + + + + +
+ + {title === "Conversion Rate" && ( +
+
+
+
+

22% conversion rate

+
+ )} + + {(title === "Closed" || title === "Conversion Rate") && ( +
+ )} ) diff --git a/src/components/layout/topbar.tsx b/src/components/layout/topbar.tsx index 7c7d0f1..1684ae8 100644 --- a/src/components/layout/topbar.tsx +++ b/src/components/layout/topbar.tsx @@ -3,11 +3,11 @@ import { useState, useEffect } from "react"; import { useRouter } from "next/navigation"; import { useTheme } from "next-themes"; +import { cn } from "@/lib/utils"; 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, @@ -38,7 +38,8 @@ export function Topbar({ onMenuClick }: TopbarProps) { const router = useRouter(); const { theme, setTheme } = useTheme(); const { user, logout } = useUser(); - const { notifications, unreadCount, markAsRead, markAllAsRead, dismiss } = useNotifications(); + const { notifications, unreadCount, markAsRead, markAllAsRead, dismiss } = + useNotifications(); const [mounted, setMounted] = useState(false); const [searchOpen, setSearchOpen] = useState(false); @@ -48,15 +49,15 @@ export function Topbar({ onMenuClick }: TopbarProps) { if (!user) return null; function formatTimeAgo(ts: string): string { - const diff = Date.now() - new Date(ts).getTime() - const mins = Math.floor(diff / 60000) - if (mins < 1) return "Just now" - if (mins < 60) return `${mins}m ago` - const hrs = Math.floor(mins / 60) - if (hrs < 24) return `${hrs}h ago` - const days = Math.floor(hrs / 24) - if (days < 7) return `${days}d ago` - return new Date(ts).toLocaleDateString() + const diff = Date.now() - new Date(ts).getTime(); + const mins = Math.floor(diff / 60000); + if (mins < 1) return "Just now"; + if (mins < 60) return `${mins}m ago`; + const hrs = Math.floor(mins / 60); + if (hrs < 24) return `${hrs}h ago`; + const days = Math.floor(hrs / 24); + if (days < 7) return `${days}d ago`; + return new Date(ts).toLocaleDateString(); } const initials = user.name .split(" ") @@ -66,6 +67,14 @@ export function Topbar({ onMenuClick }: TopbarProps) { return (
+ {/* Logo */} +
+
+ + CRM + +
+ {/* Mobile menu button */} @@ -156,8 +163,8 @@ export function Topbar({ onMenuClick }: TopbarProps) { role="button" tabIndex={0} onClick={() => { - if (!n.read) markAsRead(n.id) - if (n.link) router.push(n.link) + if (!n.read) markAsRead(n.id); + if (n.link) router.push(n.link); }} className={`flex cursor-pointer items-start gap-3 rounded-lg p-2 transition-colors hover:bg-muted/50 ${!n.read ? "bg-muted/30" : ""}`} > @@ -166,15 +173,17 @@ export function Topbar({ onMenuClick }: TopbarProps) { />

{n.title}

-

{n.description}

+

+ {n.description} +

{formatTimeAgo(n.timestamp)}

- - ) + ); } diff --git a/src/components/users/users-table.tsx b/src/components/users/users-table.tsx index d597e6b..0c25783 100644 --- a/src/components/users/users-table.tsx +++ b/src/components/users/users-table.tsx @@ -11,17 +11,31 @@ import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { Checkbox } from "@/components/ui/checkbox" import { User } from "@/types" -import { Pencil, Power, PowerOff } from "lucide-react" +import { Pencil, Trash2 } from "lucide-react" +import { toast } from "sonner" interface UsersTableProps { data: User[] loading?: boolean + onUserDeleted?: () => void } -export function UsersTable({ data, loading }: UsersTableProps) { +export function UsersTable({ data, loading, onUserDeleted }: UsersTableProps) { const [editingUser, setEditingUser] = useState(null) const [editOpen, setEditOpen] = useState(false) + const handleDelete = async (id: string) => { + if (!confirm("Are you sure you want to delete this user?")) return + try { + const res = await fetch(`/api/users/${id}`, { method: "DELETE" }) + if (!res.ok) throw new Error() + toast.success("User deleted") + onUserDeleted?.() + } catch { + toast.error("Failed to delete user") + } + } + const columns: ColumnDef[] = useMemo( () => [ { @@ -76,15 +90,15 @@ export function UsersTable({ data, loading }: UsersTableProps) { ), cell: ({ row }) => { const role = row.getValue("role") as string + const colors: Record = { + super_admin: "bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20", + admin: "bg-blue-500/10 text-blue-600 dark:text-blue-400 border-blue-500/20", + sales_user: "bg-purple-500/10 text-purple-600 dark:text-purple-400 border-purple-500/20", + developer: "bg-zinc-500/10 text-zinc-600 dark:text-zinc-400 border-zinc-500/20", + } return ( - - {role} + + {role.replace("_", " ")} ) }, @@ -117,8 +131,9 @@ export function UsersTable({ data, loading }: UsersTableProps) { variant="ghost" size="icon" className="h-8 w-8 text-muted-foreground hover:text-destructive" + onClick={() => handleDelete(user.id)} > - {user.active ? : } +
) @@ -140,6 +155,7 @@ export function UsersTable({ data, loading }: UsersTableProps) { open={editOpen} onOpenChange={setEditOpen} user={editingUser} + onUserCreated={onUserDeleted} /> ) diff --git a/src/data/dashboard.ts b/src/data/dashboard.ts index 3e05fdf..55e0c32 100644 --- a/src/data/dashboard.ts +++ b/src/data/dashboard.ts @@ -29,14 +29,14 @@ export function getDashboardStats(period: string): DashboardStats { 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 [ + { 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 { totalLeads, diff --git a/src/data/leads.ts b/src/data/leads.ts index 2be1d0c..a4609e4 100644 --- a/src/data/leads.ts +++ b/src/data/leads.ts @@ -26,22 +26,28 @@ const lastNames = ["Smith", "Johnson", "Williams", "Brown", "Jones", "Garcia", " const sources = ["Website", "Referral", "LinkedIn", "Cold Call", "Email", "Google", "Social Media", "Other"] const statuses: LeadStatus[] = ["open", "contacted", "pending", "closed", "ignored"] +const phonePrefixes = ["555", "123", "456", "789", "321", "654", "987", "234", "876", "432"] + +let _rngSeed = 42 +function rng(): number { + _rngSeed = (_rngSeed * 1664525 + 1013904223) & 0x7fffffff + return _rngSeed / 0x7fffffff +} + function randomItem(arr: T[]): T { - return arr[Math.floor(Math.random() * arr.length)] + return arr[Math.floor(rng() * arr.length)] } function randomDate(startMonthsAgo: number): string { const now = new Date() const start = new Date(now.getFullYear(), now.getMonth() - startMonthsAgo, 1) - const randomTime = start.getTime() + Math.random() * (now.getTime() - start.getTime()) + const randomTime = start.getTime() + rng() * (now.getTime() - start.getTime()) return new Date(randomTime).toISOString() } -const phonePrefixes = ["555", "123", "456", "789", "321", "654", "987", "234", "876", "432"] - function randomPhone(): string { const prefix = randomItem(phonePrefixes) - const suffix = Math.floor(Math.random() * 10000000).toString().padStart(7, "0") + const suffix = Math.floor(rng() * 10000000).toString().padStart(7, "0") return `(${prefix}) ${suffix.slice(0, 3)}-${suffix.slice(3)}` } @@ -49,7 +55,7 @@ export const leads: Lead[] = Array.from({ length: 120 }, (_, i) => { const firstName = randomItem(firstNames) const lastName = randomItem(lastNames) const status = randomItem(statuses) - const assignedUser = Math.random() > 0.15 ? randomItem(users.filter((u) => u.active)) : null + const assignedUser = rng() > 0.15 ? randomItem(users.filter((u) => u.active)) : null const createdAt = randomDate(6) return { @@ -59,7 +65,7 @@ export const leads: Lead[] = Array.from({ length: 120 }, (_, i) => { email: `${firstName.toLowerCase()}.${lastName.toLowerCase()}@${companyNames[i % companyNames.length].toLowerCase().replace(/\s+/g, "")}.com`, phone: randomPhone(), source: randomItem(sources), - description: `Looking for a complete website redesign and modern digital presence. Interested in responsive design, SEO optimization, and a custom CMS solution.`, + description: "Looking for a complete website redesign and modern digital presence. Interested in responsive design, SEO optimization, and a custom CMS solution.", status, assignedUserId: assignedUser?.id ?? null, assignedUser, diff --git a/src/data/users.ts b/src/data/users.ts index 227d07f..b289885 100644 --- a/src/data/users.ts +++ b/src/data/users.ts @@ -23,7 +23,7 @@ export const users: User[] = [ id: "u2", name: "Marcus Johnson", email: "marcus@coastalit.com", - role: "sales", + role: "sales_user", active: true, avatar: "https://ui-avatars.com/api/?name=Marcus+Johnson&background=2563eb&color=fff&size=128", createdAt: "2025-02-01T09:00:00Z", @@ -32,7 +32,7 @@ export const users: User[] = [ id: "u3", name: "Emily Rodriguez", email: "emily@coastalit.com", - role: "sales", + role: "sales_user", active: true, avatar: "https://ui-avatars.com/api/?name=Emily+Rodriguez&background=3b82f6&color=fff&size=128", createdAt: "2025-02-15T10:00:00Z", @@ -41,7 +41,7 @@ export const users: User[] = [ id: "u4", name: "David Kim", email: "david@coastalit.com", - role: "sales", + role: "sales_user", active: true, avatar: "https://ui-avatars.com/api/?name=David+Kim&background=60a5fa&color=fff&size=128", createdAt: "2025-03-01T08:00:00Z", @@ -50,7 +50,7 @@ export const users: User[] = [ id: "u5", name: "Jessica Patel", email: "jessica@coastalit.com", - role: "sales", + role: "sales_user", active: false, avatar: "https://ui-avatars.com/api/?name=Jessica+Patel&background=93c5fd&color=fff&size=128", createdAt: "2025-03-10T09:00:00Z", @@ -59,7 +59,7 @@ export const users: User[] = [ id: "u6", name: "Alex Thompson", email: "alex@coastalit.com", - role: "sales", + role: "sales_user", active: true, avatar: "https://ui-avatars.com/api/?name=Alex+Thompson&background=1d4ed8&color=fff&size=128", createdAt: "2025-04-01T08:00:00Z", @@ -68,7 +68,7 @@ export const users: User[] = [ id: "u7", name: "Rachel Williams", email: "rachel@coastalit.com", - role: "sales", + role: "sales_user", active: true, avatar: "https://ui-avatars.com/api/?name=Rachel+Williams&background=2563eb&color=fff&size=128", createdAt: "2025-04-15T10:00:00Z", diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 7814960..c533292 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -1,35 +1,35 @@ -import { SignJWT, jwtVerify } from "jose" -import bcrypt from "bcryptjs" -import { query } from "@/lib/db" -import { cookies } from "next/headers" +import { SignJWT, jwtVerify } from "jose"; +import bcrypt from "bcryptjs"; +import { query } from "@/lib/db"; +import { cookies } from "next/headers"; const JWT_SECRET = new TextEncoder().encode( - process.env.JWT_SECRET || "fallback-dev-secret-do-not-use-in-production" -) + process.env.JWT_SECRET || "fallback-dev-secret-do-not-use-in-production", +); -const SESSION_COOKIE = "session" -const MAX_FAILED_ATTEMPTS = 5 -const LOCKOUT_DURATION_MINUTES = 15 +const SESSION_COOKIE = "session"; +const MAX_FAILED_ATTEMPTS = 5; +const LOCKOUT_DURATION_MINUTES = 15; export interface SessionUser { - id: string - username: string - email: string - firstName: string - lastName: string - role: string - avatar: string + id: string; + username: string; + email: string; + firstName: string; + lastName: string; + role: string; + avatar: string; } export async function hashPassword(password: string): Promise { - return bcrypt.hash(password, 12) + return bcrypt.hash(password, 12); } export async function comparePassword( password: string, - hash: string + hash: string, ): Promise { - return bcrypt.compare(password, hash) + return bcrypt.compare(password, hash); } export async function signToken(payload: { userId: string; role: string }) { @@ -37,15 +37,15 @@ export async function signToken(payload: { userId: string; role: string }) { .setProtectedHeader({ alg: "HS256" }) .setExpirationTime("24h") .setIssuedAt() - .sign(JWT_SECRET) + .sign(JWT_SECRET); } export async function verifyToken(token: string) { try { - const { payload } = await jwtVerify(token, JWT_SECRET) - return payload as { userId: string; role: string } + const { payload } = await jwtVerify(token, JWT_SECRET); + return payload as { userId: string; role: string }; } catch { - return null + return null; } } @@ -60,9 +60,9 @@ export async function getUserByEmail(email: string) { JOIN roles r ON r.id = ur.role_id WHERE u.email = $1 AND u.deleted_at IS NULL LIMIT 1`, - [email.toLowerCase().trim()] - ) - return result.rows[0] || null + [email.toLowerCase().trim()], + ); + return result.rows[0] || null; } export async function getUserByUsername(username: string) { @@ -76,9 +76,9 @@ export async function getUserByUsername(username: string) { JOIN roles r ON r.id = ur.role_id WHERE u.username = $1 AND u.deleted_at IS NULL LIMIT 1`, - [username.toLowerCase().trim()] - ) - return result.rows[0] || null + [username.toLowerCase().trim()], + ); + return result.rows[0] || null; } export async function getUserById(id: string) { @@ -91,9 +91,9 @@ export async function getUserById(id: string) { JOIN roles r ON r.id = ur.role_id WHERE u.id = $1 AND u.deleted_at IS NULL LIMIT 1`, - [id] - ) - return result.rows[0] || null + [id], + ); + return result.rows[0] || null; } export async function recordLoginAttempt( @@ -102,13 +102,20 @@ export async function recordLoginAttempt( ipAddress: string, userAgent: string | null, wasSuccessful: boolean, - failureReason?: string + failureReason?: string, ) { await query( `INSERT INTO login_attempts (user_id, username_attempted, ip_address, user_agent, was_successful, failure_reason) VALUES ($1, $2, $3, $4, $5, $6)`, - [userId, usernameAttempted, ipAddress, userAgent, wasSuccessful, failureReason || null] - ) + [ + userId, + usernameAttempted, + ipAddress, + userAgent, + wasSuccessful, + failureReason || null, + ], + ); } export async function incrementFailedAttempts(userId: string) { @@ -121,8 +128,8 @@ export async function incrementFailedAttempts(userId: string) { ELSE locked_until END WHERE id = $1`, - [userId, MAX_FAILED_ATTEMPTS, LOCKOUT_DURATION_MINUTES] - ) + [userId, MAX_FAILED_ATTEMPTS, LOCKOUT_DURATION_MINUTES], + ); } export async function resetFailedAttempts(userId: string) { @@ -132,38 +139,38 @@ export async function resetFailedAttempts(userId: string) { locked_until = NULL, last_login_at = NOW() WHERE id = $1`, - [userId] - ) + [userId], + ); } export async function isAccountLocked(user: { - is_locked: boolean - locked_until: Date | null + is_locked: boolean; + locked_until: Date | null; }): Promise<{ locked: boolean; reason?: string }> { if (user.is_locked) { - return { locked: true, reason: "Account has been locked by an administrator." } + return { + locked: true, + reason: "Account has been locked by an administrator.", + }; } if (user.locked_until && new Date(user.locked_until) > new Date()) { const minutes = Math.ceil( - (new Date(user.locked_until).getTime() - Date.now()) / 60000 - ) + (new Date(user.locked_until).getTime() - Date.now()) / 60000, + ); return { locked: true, reason: `Account is temporarily locked due to too many failed attempts. Try again in ${minutes} minute(s).`, - } + }; } - return { locked: false } + return { locked: false }; } -export function mapDbUserToSessionUser(dbUser: Record): SessionUser { - const roleName = dbUser.role_name as string - const roleMap: Record = { - SUPER_ADMIN: "super_admin", - ADMIN: "admin", - DEVELOPER: "developer", - SALES_USER: "sales", - } - const mappedRole = roleMap[roleName] || "sales" +export function mapDbUserToSessionUser( + dbUser: Record, +): SessionUser { + const roleName = dbUser.role_name as string; + const mappedRole = + roleName === "SUPER_ADMIN" || roleName === "ADMIN" ? "admin" : "sales"; return { id: dbUser.id as string, @@ -171,53 +178,53 @@ export function mapDbUserToSessionUser(dbUser: Record): Session email: dbUser.email as string, firstName: dbUser.first_name as string, lastName: dbUser.last_name as string, - role: mappedRole, + role: roleName, avatar: `https://ui-avatars.com/api/?name=${encodeURIComponent( - `${dbUser.first_name}+${dbUser.last_name}` + `${dbUser.first_name}+${dbUser.last_name}`, )}&background=1d4ed8&color=fff&size=128`, - } + }; } export async function createSession(userId: string, role: string) { - const token = await signToken({ userId, role }) + const token = await signToken({ userId, role }); - const cookieStore = await cookies() + const cookieStore = await cookies(); cookieStore.set(SESSION_COOKIE, token, { httpOnly: true, secure: process.env.NODE_ENV === "production", sameSite: "lax", path: "/", maxAge: 60 * 60 * 24, // 24 hours - }) + }); - return token + return token; } export async function destroySession() { - const cookieStore = await cookies() + const cookieStore = await cookies(); cookieStore.set(SESSION_COOKIE, "", { httpOnly: true, secure: process.env.NODE_ENV === "production", sameSite: "lax", path: "/", maxAge: 0, - }) + }); } export async function getSessionUser(): Promise { try { - const cookieStore = await cookies() - const token = cookieStore.get(SESSION_COOKIE)?.value - if (!token) return null + const cookieStore = await cookies(); + const token = cookieStore.get(SESSION_COOKIE)?.value; + if (!token) return null; - const payload = await verifyToken(token) - if (!payload) return null + const payload = await verifyToken(token); + if (!payload) return null; - const dbUser = await getUserById(payload.userId) - if (!dbUser) return null + const dbUser = await getUserById(payload.userId); + if (!dbUser) return null; - return mapDbUserToSessionUser(dbUser) + return mapDbUserToSessionUser(dbUser); } catch { - return null + return null; } } diff --git a/src/types/index.ts b/src/types/index.ts index 6895ed7..33c1021 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,89 +1,99 @@ -export type LeadStatus = "open" | "contacted" | "pending" | "closed" | "ignored" -export type UserRole = "super_admin" | "admin" | "developer" | "sales" +export type LeadStatus = + | "open" + | "contacted" + | "pending" + | "closed" + | "ignored"; +export type UserRole = "admin" | "sales"; export interface User { - id: string - name: string - email: string - role: UserRole - active: boolean - avatar: string - createdAt: string + id: string; + name: string; + email: string; + role: UserRole; + active: boolean; + avatar: string; + createdAt: string; } export interface Lead { - id: string - companyName: string - contactName: string - email: string - phone: string - source: string - description: string - status: LeadStatus - assignedUserId: string | null - assignedUser: User | null - createdAt: string - updatedAt: string + id: string; + companyName: string; + contactName: string; + email: string; + phone: string; + source: string; + description: string; + status: LeadStatus; + assignedUserId: string | null; + assignedUser: User | null; + createdAt: string; + updatedAt: string; } export interface Note { - id: string - leadId: string - userId: string - authorName: string - authorAvatar: string - authorRole: UserRole - note: string - createdAt: string - updatedAt: string + id: string; + leadId: string; + userId: string; + authorName: string; + authorAvatar: string; + authorRole: UserRole; + note: string; + createdAt: string; + updatedAt: string; } export interface DashboardStats { - totalLeads: number - openLeads: number - contactedLeads: number - pendingLeads: number - closedLeads: number - ignoredLeads: number - conversionRate: number - leadsPerMonth: { label: string; leads: number; closed: number }[] - recentLeads: Lead[] - statusDistribution: { name: string; value: number; color: string }[] - periodLabel: string + totalLeads: number; + openLeads: number; + contactedLeads: number; + pendingLeads: number; + closedLeads: number; + ignoredLeads: number; + conversionRate: number; + leadsPerMonth: { label: string; leads: number; closed: number }[]; + recentLeads: Lead[]; + statusDistribution: { name: string; value: number; color: string }[]; + periodLabel: string; } export interface ColumnFilter { - id: string - value: unknown + id: string; + value: unknown; } -export type NotificationType = "lead_created" | "lead_status_changed" | "lead_assigned" | "chat_message" | "note_added" +export type NotificationType = + | "lead_created" + | "lead_status_changed" + | "lead_assigned" + | "chat_message" + | "note_added"; export interface Notification { - id: string - type: NotificationType - title: string - description: string - timestamp: string - read: boolean - link?: string + id: string; + type: NotificationType; + title: string; + description: string; + timestamp: string; + read: boolean; + link?: string; } export interface ChatMessage { - id: string - conversationId: string - senderId: string - senderName: string - senderAvatar: string - content: string - timestamp: string + id: string; + conversationId: string; + senderId: string; + senderName: string; + senderAvatar: string; + content: string; + timestamp: string; } export interface Conversation { - id: string - participants: { id: string; name: string; avatar: string; role: string }[] - lastMessage: string - lastMessageTime: string - unread: number - messages: ChatMessage[] + id: string; + participants: { id: string; name: string; avatar: string; role: string }[]; + lastMessage: string; + lastMessageTime: string; + unread: number; + messages: ChatMessage[]; }