Compare commits

...

3 Commits

Author SHA1 Message Date
caitlin 02ef2b9f1f I Fixed the notifications 2026-06-18 16:52:30 +02:00
caitlin 6fe41b6c96 I added functionality to the notification button 2026-06-18 16:47:31 +02:00
caitlin 1e4b8df8dd I added functioonality to the notifications 2026-06-18 16:43:26 +02:00
14 changed files with 621 additions and 274 deletions
+4 -1
View File
@@ -2,6 +2,7 @@
import { AppShell } from "@/components/layout/app-shell" import { AppShell } from "@/components/layout/app-shell"
import { UserProvider, useUser } from "@/providers/user-provider" import { UserProvider, useUser } from "@/providers/user-provider"
import { NotificationProvider } from "@/providers/notification-provider"
import { Loader2 } from "lucide-react" import { Loader2 } from "lucide-react"
function DashboardContent({ children }: { children: React.ReactNode }) { function DashboardContent({ children }: { children: React.ReactNode }) {
@@ -27,7 +28,9 @@ export default function DashboardLayout({
}) { }) {
return ( return (
<UserProvider> <UserProvider>
<DashboardContent>{children}</DashboardContent> <NotificationProvider>
<DashboardContent>{children}</DashboardContent>
</NotificationProvider>
</UserProvider> </UserProvider>
) )
} }
+8
View File
@@ -29,6 +29,7 @@ import { ArrowLeft } from "lucide-react"
import { Lead, LeadStatus } from "@/types" import { Lead, LeadStatus } from "@/types"
import { leads } from "@/data/leads" import { leads } from "@/data/leads"
import { users } from "@/data/users" import { users } from "@/data/users"
import { useNotifications } from "@/providers/notification-provider"
import { LEAD_SOURCES, LEAD_STATUSES } from "@/lib/constants" import { LEAD_SOURCES, LEAD_STATUSES } from "@/lib/constants"
const leadFormSchema = z.object({ const leadFormSchema = z.object({
@@ -46,6 +47,7 @@ type LeadFormValues = z.infer<typeof leadFormSchema>
export default function CreateLeadPage() { export default function CreateLeadPage() {
const router = useRouter() const router = useRouter()
const { addNotification } = useNotifications()
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const form = useForm<LeadFormValues>({ const form = useForm<LeadFormValues>({
@@ -84,6 +86,12 @@ export default function CreateLeadPage() {
} }
leads.unshift(newLead) leads.unshift(newLead)
addNotification(
"lead_created",
"New Lead Created",
`${newLead.companyName}${newLead.contactName}`,
`/leads/${newLead.id}`
)
router.push("/leads") router.push("/leads")
} }
+35 -56
View File
@@ -1,54 +1,23 @@
"use client" "use client";
import { useState, useEffect, useCallback } from "react" import { useState } from "react";
import { PageHeader } from "@/components/shared/page-header" import { PageHeader } from "@/components/shared/page-header";
import { UsersTable } from "@/components/users/users-table" import { UsersTable } from "@/components/users/users-table";
import { UserFormDialog } from "@/components/users/user-form-dialog" import { UserFormDialog } from "@/components/users/user-form-dialog";
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input" import { users } from "@/data/users";
import { Plus, Search, Users as UsersIcon } from "lucide-react" import { Plus, Users as UsersIcon } from "lucide-react";
import { User } from "@/types"
import { toast } from "sonner"
export default function UsersPage() { export default function UsersPage() {
const [createOpen, setCreateOpen] = useState(false) const { user } = useUser();
const [users, setUsers] = useState<User[]>([]) const [createOpen, setCreateOpen] = useState(false);
const [loading, setLoading] = useState(true)
const [searchQuery, setSearchQuery] = useState("")
const fetchUsers = useCallback(async () => {
try {
const res = await fetch("/api/users")
const data = await res.json()
if (data.users) setUsers(data.users)
} catch {
toast.error("Failed to load users")
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
fetchUsers()
}, [fetchUsers])
const filteredUsers = searchQuery.trim()
? users.filter((u) =>
u.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
u.email.toLowerCase().includes(searchQuery.toLowerCase())
)
: users
const stats = [
{ label: "Total Users", value: users.length, color: "bg-blue-500/10", textColor: "text-blue-600 dark:text-blue-400" },
{ label: "Active", value: users.filter((u) => u.active).length, color: "bg-emerald-500/10", textColor: "text-emerald-600 dark:text-emerald-400" },
{ label: "Admins", value: users.filter((u) => u.role === "admin" || u.role === "super_admin").length, color: "bg-purple-500/10", textColor: "text-purple-600 dark:text-purple-400" },
{ label: "Sales", value: users.filter((u) => u.role === "sales_user").length, color: "bg-amber-500/10", textColor: "text-amber-600 dark:text-amber-400" },
]
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<PageHeader title="Users" description="Manage team members and their roles"> <PageHeader
title="Users"
description="Manage team members and their roles"
>
<Button className="gap-2" onClick={() => setCreateOpen(true)}> <Button className="gap-2" onClick={() => setCreateOpen(true)}>
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
Add User Add User
@@ -59,7 +28,9 @@ export default function UsersPage() {
{stats.map((s) => ( {stats.map((s) => (
<div key={s.label} className="rounded-lg border bg-card p-4"> <div key={s.label} className="rounded-lg border bg-card p-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className={`flex h-10 w-10 items-center justify-center rounded-lg ${s.color}`}> <div
className={`flex h-10 w-10 items-center justify-center rounded-lg ${s.color}`}
>
<UsersIcon className={`h-5 w-5 ${s.textColor}`} /> <UsersIcon className={`h-5 w-5 ${s.textColor}`} />
</div> </div>
<div> <div>
@@ -71,17 +42,25 @@ export default function UsersPage() {
))} ))}
</div> </div>
<div className="rounded-lg border bg-card"> <div className="relative">
<div className="flex items-center gap-2 p-4 pb-0"> <Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<div className="relative flex-1 max-w-sm"> <Input
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" /> placeholder="Search by name or email..."
<Input value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} placeholder="Search users by name or email..." className="h-9 pl-9" /> value={search}
</div> onChange={(e) => setSearch(e.target.value)}
</div> className="h-9 pl-9"
<UsersTable data={filteredUsers} loading={loading} onUserDeleted={fetchUsers} /> />
</div> </div>
<UserFormDialog open={createOpen} onOpenChange={setCreateOpen} onUserCreated={fetchUsers} /> <div className="rounded-lg border bg-card">
<UsersTable data={users} />
</div>
<UserFormDialog
open={createOpen}
onOpenChange={setCreateOpen}
onUserCreated={fetchUsers}
/>
</div> </div>
) );
} }
+16
View File
@@ -0,0 +1,16 @@
export const metadata = {
title: 'Next.js',
description: 'Generated by Next.js',
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body>{children}</body>
</html>
)
}
@@ -36,6 +36,21 @@ export function LeadStatusChart({ data }: LeadStatusChartProps) {
const total = data.reduce((s, d) => s + d.value, 0) const total = data.reduce((s, d) => s + d.value, 0)
if (total === 0) {
return (
<Card className="h-full">
<CardHeader>
<CardTitle>Lead Status</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center justify-center h-[400px] text-sm text-muted-foreground">
No data for this period
</div>
</CardContent>
</Card>
)
}
let cum = 0 let cum = 0
const segs = data.map((d) => { const segs = data.map((d) => {
const start = cum const start = cum
@@ -27,8 +27,9 @@ export function LeadsPerMonthChart({ data }: LeadsPerMonthChartProps) {
const chartH = height - padding.top - padding.bottom const chartH = height - padding.top - padding.bottom
const maxVal = useMemo(() => { const maxVal = useMemo(() => {
if (data.length === 0) return 1
const max = Math.max(...data.map((d) => Math.max(d.leads, d.closed))) const max = Math.max(...data.map((d) => Math.max(d.leads, d.closed)))
return Math.ceil(max / 7) * 7 return Math.max(Math.ceil(max / 7) * 7, 1)
}, [data]) }, [data])
const ticks = useMemo(() => { const ticks = useMemo(() => {
@@ -49,6 +50,21 @@ export function LeadsPerMonthChart({ data }: LeadsPerMonthChartProps) {
const totalClosed = data.reduce((s, d) => s + d.closed, 0) const totalClosed = data.reduce((s, d) => s + d.closed, 0)
const closeRate = totalNew > 0 ? Math.round((totalClosed / totalNew) * 100) : 0 const closeRate = totalNew > 0 ? Math.round((totalClosed / totalNew) * 100) : 0
if (data.length === 0) {
return (
<Card className="h-full">
<CardHeader>
<CardTitle>Leads Per Month</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center justify-center h-[400px] text-sm text-muted-foreground">
No data for this period
</div>
</CardContent>
</Card>
)
}
return ( return (
<motion.div <motion.div
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
+4 -5
View File
@@ -20,9 +20,7 @@ import {
} from "lucide-react" } from "lucide-react"
import { COMPANY_NAME } from "@/lib/constants" import { COMPANY_NAME } from "@/lib/constants"
import { useUser } from "@/providers/user-provider" import { useUser } from "@/providers/user-provider"
import { conversations as conversationsData } from "@/data/chats" import { useNotifications } from "@/providers/notification-provider"
const totalUnread = conversationsData.reduce((sum, c) => sum + c.unread, 0)
const navItems = [ const navItems = [
{ href: "/dashboard", label: "Dashboard", icon: LayoutDashboard }, { href: "/dashboard", label: "Dashboard", icon: LayoutDashboard },
@@ -42,6 +40,7 @@ interface SidebarProps {
export function Sidebar({ collapsed, onToggle, mobileOpen, onMobileClose }: SidebarProps) { export function Sidebar({ collapsed, onToggle, mobileOpen, onMobileClose }: SidebarProps) {
const pathname = usePathname() const pathname = usePathname()
const { user } = useUser() const { user } = useUser()
const { unreadChatCount } = useNotifications()
if (!user) return null if (!user) return null
const initials = user.name.split(" ").map((n) => n[0]).join("") const initials = user.name.split(" ").map((n) => n[0]).join("")
@@ -104,9 +103,9 @@ export function Sidebar({ collapsed, onToggle, mobileOpen, onMobileClose }: Side
)} )}
> >
<item.icon className="h-5 w-5" /> <item.icon className="h-5 w-5" />
{item.label === "Chats" && totalUnread > 0 && ( {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"> <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">
{totalUnread} {unreadChatCount}
</span> </span>
)} )}
</Link> </Link>
+131 -44
View File
@@ -1,12 +1,13 @@
"use client" "use client";
import { useState, useEffect } from "react" import { useState, useEffect } from "react";
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation";
import { useTheme } from "next-themes" import { useTheme } from "next-themes";
import { Button } from "@/components/ui/button" import { cn } from "@/lib/utils";
import { Input } from "@/components/ui/input" import { Button } from "@/components/ui/button";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" import { Input } from "@/components/ui/input";
import { useUser } from "@/providers/user-provider" import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { useUser } from "@/providers/user-provider";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@@ -14,8 +15,8 @@ import {
DropdownMenuLabel, DropdownMenuLabel,
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu" } from "@/components/ui/dropdown-menu";
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge";
import { import {
Search, Search,
Bell, Bell,
@@ -25,33 +26,62 @@ import {
LogOut, LogOut,
User, User,
Settings, Settings,
} from "lucide-react" CheckCheck,
Trash2,
} from "lucide-react";
interface TopbarProps { interface TopbarProps {
onMenuClick: () => void onMenuClick: () => void;
} }
export function Topbar({ onMenuClick }: TopbarProps) { export function Topbar({ onMenuClick }: TopbarProps) {
const router = useRouter() const router = useRouter();
const { theme, setTheme } = useTheme() const { theme, setTheme } = useTheme();
const { user, logout } = useUser() const { user, logout } = useUser();
const [mounted, setMounted] = useState(false) const { notifications, unreadCount, markAsRead, markAllAsRead, dismiss } =
const [searchOpen, setSearchOpen] = useState(false) useNotifications();
const [mounted, setMounted] = useState(false);
const [searchOpen, setSearchOpen] = useState(false);
useEffect(() => { setMounted(true) }, []) useEffect(() => {
if (!user) return null setMounted(true);
const initials = user.name.split(" ").map((n: string) => n[0]).join("").toUpperCase() }, []);
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 initials = user.name
.split(" ")
.map((n: string) => n[0])
.join("")
.toUpperCase();
return ( return (
<header className="sticky top-0 z-20 flex h-16 items-center gap-4 border-b bg-background px-4 lg:px-6"> <header className="sticky top-0 z-20 flex h-16 items-center gap-4 border-b bg-background px-4 lg:px-6">
{/* Logo */} {/* Logo */}
<div className="flex items-center gap-2 mr-2 shrink-0"> <div className="flex items-center gap-2 mr-2 shrink-0">
<div className="w-3 h-3 rounded-full bg-[#0d9488]" /> <div className="w-3 h-3 rounded-full bg-[#0d9488]" />
<span className="text-[#0d9488] font-bold text-sm tracking-wide">CRM</span> <span className="text-[#0d9488] font-bold text-sm tracking-wide">
CRM
</span>
</div> </div>
{/* Mobile menu button */} {/* Mobile menu button */}
<Button variant="ghost" size="icon" className="lg:hidden" onClick={onMenuClick}> <Button
variant="ghost"
size="icon"
className="lg:hidden"
onClick={onMenuClick}
>
<Menu className="h-5 w-5" /> <Menu className="h-5 w-5" />
</Button> </Button>
@@ -82,37 +112,87 @@ export function Topbar({ onMenuClick }: TopbarProps) {
onClick={() => setTheme(theme === "dark" ? "light" : "dark")} onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
className="text-muted-foreground" className="text-muted-foreground"
> >
{mounted ? (theme === "dark" ? <Sun className="h-5 w-5" /> : <Moon className="h-5 w-5" />) : <div className="h-5 w-5" />} {mounted ? (
theme === "dark" ? (
<Sun className="h-5 w-5" />
) : (
<Moon className="h-5 w-5" />
)
) : (
<div className="h-5 w-5" />
)}
</Button> </Button>
{/* Notifications */} {/* Notifications */}
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="relative text-muted-foreground"> <Button
variant="ghost"
size="icon"
className="relative text-muted-foreground"
>
<Bell className="h-5 w-5" /> <Bell className="h-5 w-5" />
<span className="absolute -right-0.5 -top-0.5 flex h-4 w-4 items-center justify-center rounded-full bg-[#f43f5e] text-white text-[10px] font-medium"> <span className="absolute -right-0.5 -top-0.5 flex h-4 w-4 items-center justify-center rounded-full bg-primary text-[10px] font-medium text-primary-foreground">
3 3
</span> </span>
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-80"> <DropdownMenuContent align="end" className="w-80">
<DropdownMenuLabel>Notifications</DropdownMenuLabel> <DropdownMenuLabel className="flex items-center justify-between">
<span>Notifications</span>
{notifications.length > 0 && (
<button
onClick={markAllAsRead}
className="flex items-center gap-1 text-xs text-primary hover:underline"
>
<CheckCheck className="h-3 w-3" />
Mark all read
</button>
)}
</DropdownMenuLabel>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<div className="space-y-1 p-2"> {notifications.length === 0 ? (
{[ <div className="py-6 text-center text-sm text-muted-foreground">
{ text: "New lead assigned to you", time: "5m ago" }, No notifications
{ text: "Lead status updated to Closed", time: "1h ago" }, </div>
{ text: "Note added to Brightwave Studios", time: "3h ago" }, ) : (
].map((n, i) => ( <div className="max-h-[360px] space-y-1 overflow-y-auto p-2">
<div key={i} className="flex items-start gap-3 rounded-lg p-2 hover:bg-muted/50"> {notifications.slice(0, 20).map((n) => (
<div className="h-2 w-2 mt-2 rounded-full bg-primary shrink-0" /> <div
<div className="flex-1 space-y-0.5"> key={n.id}
<p className="text-sm">{n.text}</p> role="button"
<p className="text-xs text-muted-foreground">{n.time}</p> tabIndex={0}
onClick={() => {
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" : ""}`}
>
<div
className={`mt-2 h-2 w-2 shrink-0 rounded-full ${n.read ? "bg-transparent" : "bg-primary"}`}
/>
<div className="flex-1 space-y-0.5">
<p className="text-sm font-medium">{n.title}</p>
<p className="text-xs text-muted-foreground">
{n.description}
</p>
<p className="text-[10px] text-muted-foreground/60">
{formatTimeAgo(n.timestamp)}
</p>
</div>
<button
onClick={(e) => {
e.stopPropagation();
dismiss(n.id);
}}
className="mt-1 shrink-0 text-muted-foreground/40 hover:text-destructive"
>
<Trash2 className="h-3 w-3" />
</button>
</div> </div>
</div> ))}
))} </div>
</div> )}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
@@ -124,7 +204,9 @@ export function Topbar({ onMenuClick }: TopbarProps) {
<AvatarImage src={user.avatar} /> <AvatarImage src={user.avatar} />
<AvatarFallback>{initials}</AvatarFallback> <AvatarFallback>{initials}</AvatarFallback>
</Avatar> </Avatar>
<span className="hidden text-sm font-medium md:inline-block">{user.name}</span> <span className="hidden text-sm font-medium md:inline-block">
{user.name}
</span>
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56"> <DropdownMenuContent align="end" className="w-56">
@@ -132,7 +214,12 @@ export function Topbar({ onMenuClick }: TopbarProps) {
<div className="flex flex-col space-y-1"> <div className="flex flex-col space-y-1">
<p className="text-sm font-medium">{user.name}</p> <p className="text-sm font-medium">{user.name}</p>
<p className="text-xs text-muted-foreground">{user.email}</p> <p className="text-xs text-muted-foreground">{user.email}</p>
<Badge variant="secondary" className="mt-1 w-fit text-xs capitalize">{user.role}</Badge> <Badge
variant="secondary"
className="mt-1 w-fit text-xs capitalize"
>
{user.role}
</Badge>
</div> </div>
</DropdownMenuLabel> </DropdownMenuLabel>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
@@ -153,5 +240,5 @@ export function Topbar({ onMenuClick }: TopbarProps) {
</DropdownMenu> </DropdownMenu>
</div> </div>
</header> </header>
) );
} }
+11
View File
@@ -33,6 +33,7 @@ import {
import { Lead, LeadStatus, User } from "@/types" import { Lead, LeadStatus, User } from "@/types"
import { LEAD_SOURCES, LEAD_STATUSES } from "@/lib/constants" import { LEAD_SOURCES, LEAD_STATUSES } from "@/lib/constants"
import { users } from "@/data/users" import { users } from "@/data/users"
import { useNotifications } from "@/providers/notification-provider"
const leadFormSchema = z.object({ const leadFormSchema = z.object({
companyName: z.string().min(1, "Company name is required"), companyName: z.string().min(1, "Company name is required"),
@@ -55,6 +56,7 @@ interface LeadFormDialogProps {
export function LeadFormDialog({ open, onOpenChange, lead }: LeadFormDialogProps) { export function LeadFormDialog({ open, onOpenChange, lead }: LeadFormDialogProps) {
const isEditing = !!lead const isEditing = !!lead
const { addNotification } = useNotifications()
const form = useForm<LeadFormValues>({ const form = useForm<LeadFormValues>({
resolver: zodResolver(leadFormSchema), resolver: zodResolver(leadFormSchema),
@@ -101,6 +103,15 @@ export function LeadFormDialog({ open, onOpenChange, lead }: LeadFormDialogProps
...values, ...values,
assignedUserId: values.assignedUserId === "none" ? null : values.assignedUserId, assignedUserId: values.assignedUserId === "none" ? null : values.assignedUserId,
} }
if (!isEditing) {
const now = new Date().toISOString()
addNotification(
"lead_created",
"New Lead Created",
`${values.companyName}${values.contactName}`,
"#"
)
}
console.log(payload) console.log(payload)
onOpenChange(false) onOpenChange(false)
} }
+77 -46
View File
@@ -1,9 +1,9 @@
"use client" "use client";
import { useState, useEffect } from "react" import { useState, useEffect } from "react";
import { useForm } from "react-hook-form" import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod" import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod" import * as z from "zod";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -11,8 +11,8 @@ import {
DialogFooter, DialogFooter,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog" } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button";
import { import {
Form, Form,
FormControl, FormControl,
@@ -20,18 +20,18 @@ import {
FormItem, FormItem,
FormLabel, FormLabel,
FormMessage, FormMessage,
} from "@/components/ui/form" } from "@/components/ui/form";
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input";
import { import {
Select, Select,
SelectContent, SelectContent,
SelectItem, SelectItem,
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select" } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch" import { Switch } from "@/components/ui/switch";
import { User } from "@/types" import { User } from "@/types";
import { toast } from "sonner" import { toast } from "sonner";
const createSchema = z.object({ const createSchema = z.object({
name: z.string().min(1, "Name is required"), name: z.string().min(1, "Name is required"),
@@ -39,22 +39,25 @@ const createSchema = z.object({
password: z.string().min(6, "Password must be at least 6 characters"), password: z.string().min(6, "Password must be at least 6 characters"),
role: z.string().min(1, "Role is required"), role: z.string().min(1, "Role is required"),
active: z.boolean(), active: z.boolean(),
}) });
type UserFormValues = z.infer<typeof createSchema>
type UserFormValues = z.infer<typeof createSchema>;
interface UserFormDialogProps { interface UserFormDialogProps {
open: boolean open: boolean;
onOpenChange: (open: boolean) => void onOpenChange: (open: boolean) => void;
user?: User | null user?: User | null;
onUserCreated?: () => void onUserCreated?: () => void;
} }
export function UserFormDialog({ open, onOpenChange, user, onUserCreated }: UserFormDialogProps) { export function UserFormDialog({
const isEditing = !!user open,
const [submitting, setSubmitting] = useState(false) onOpenChange,
user,
onUserCreated,
}: UserFormDialogProps) {
const isEditing = !!user;
const [submitting, setSubmitting] = useState(false);
const form = useForm<UserFormValues>({ const form = useForm<UserFormValues>({
resolver: zodResolver(createSchema), resolver: zodResolver(createSchema),
@@ -65,7 +68,7 @@ export function UserFormDialog({ open, onOpenChange, user, onUserCreated }: User
role: "sales_user", role: "sales_user",
active: true, active: true,
}, },
}) });
useEffect(() => { useEffect(() => {
if (user) { if (user) {
@@ -75,32 +78,38 @@ export function UserFormDialog({ open, onOpenChange, user, onUserCreated }: User
password: "", password: "",
role: user.role, role: user.role,
active: user.active, active: user.active,
}) });
} else { } else {
form.reset({ name: "", email: "", password: "", role: "sales_user", active: true }) form.reset({
name: "",
email: "",
password: "",
role: "sales_user",
active: true,
});
} }
}, [user, form]) }, [user, form]);
async function onSubmit(values: UserFormValues) { async function onSubmit(values: UserFormValues) {
setSubmitting(true) setSubmitting(true);
try { try {
const res = await fetch("/api/users", { const res = await fetch("/api/users", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(values), body: JSON.stringify(values),
}) });
const data = await res.json() const data = await res.json();
if (!res.ok) { if (!res.ok) {
toast.error(data.error || "Failed to create user") toast.error(data.error || "Failed to create user");
return return;
} }
toast.success("User created") toast.success("User created");
onOpenChange(false) onOpenChange(false);
onUserCreated?.() onUserCreated?.();
} catch { } catch {
toast.error("Failed to create user") toast.error("Failed to create user");
} finally { } finally {
setSubmitting(false) setSubmitting(false);
} }
} }
@@ -137,7 +146,11 @@ export function UserFormDialog({ open, onOpenChange, user, onUserCreated }: User
<FormItem> <FormItem>
<FormLabel>Email</FormLabel> <FormLabel>Email</FormLabel>
<FormControl> <FormControl>
<Input placeholder="john@coastit.co.za" type="email" {...field} /> <Input
placeholder="john@coastit.co.za"
type="email"
{...field}
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -149,7 +162,10 @@ export function UserFormDialog({ open, onOpenChange, user, onUserCreated }: User
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Role</FormLabel> <FormLabel>Role</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}> <Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<FormControl> <FormControl>
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Select role" /> <SelectValue placeholder="Select role" />
@@ -158,8 +174,7 @@ export function UserFormDialog({ open, onOpenChange, user, onUserCreated }: User
<SelectContent> <SelectContent>
<SelectItem value="super_admin">Super Admin</SelectItem> <SelectItem value="super_admin">Super Admin</SelectItem>
<SelectItem value="admin">Admin</SelectItem> <SelectItem value="admin">Admin</SelectItem>
<SelectItem value="sales_user">Sales</SelectItem> <SelectItem value="sales">Sales</SelectItem>
<SelectItem value="developer">Developer</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
<FormMessage /> <FormMessage />
@@ -173,7 +188,15 @@ export function UserFormDialog({ open, onOpenChange, user, onUserCreated }: User
<FormItem> <FormItem>
<FormLabel>Password</FormLabel> <FormLabel>Password</FormLabel>
<FormControl> <FormControl>
<Input type="password" placeholder={isEditing ? "Leave blank to keep current" : "Enter password"} {...field} /> <Input
type="password"
placeholder={
isEditing
? "Leave blank to keep current"
: "Enter password"
}
{...field}
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -200,16 +223,24 @@ export function UserFormDialog({ open, onOpenChange, user, onUserCreated }: User
)} )}
/> />
<DialogFooter> <DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}> <Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
>
Cancel Cancel
</Button> </Button>
<Button type="submit" disabled={submitting}> <Button type="submit" disabled={submitting}>
{submitting ? "Saving..." : isEditing ? "Save Changes" : "Create User"} {submitting
? "Saving..."
: isEditing
? "Save Changes"
: "Create User"}
</Button> </Button>
</DialogFooter> </DialogFooter>
</form> </form>
</Form> </Form>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
) );
} }
+9
View File
@@ -1,6 +1,15 @@
import { User, UserRole } from "@/types" import { User, UserRole } from "@/types"
export const users: User[] = [ export const users: User[] = [
{
id: "u-super",
name: "Jason Oosthuizen",
email: "jason@coastalit.com",
role: "super_admin",
active: true,
avatar: "https://ui-avatars.com/api/?name=Jason+Oosthuizen&background=0f172a&color=fff&size=128",
createdAt: "2025-01-01T08:00:00Z",
},
{ {
id: "u1", id: "u1",
name: "Sarah Chen", name: "Sarah Chen",
+79 -65
View File
@@ -1,35 +1,35 @@
import { SignJWT, jwtVerify } from "jose" import { SignJWT, jwtVerify } from "jose";
import bcrypt from "bcryptjs" import bcrypt from "bcryptjs";
import { query } from "@/lib/db" import { query } from "@/lib/db";
import { cookies } from "next/headers" import { cookies } from "next/headers";
const JWT_SECRET = new TextEncoder().encode( 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 SESSION_COOKIE = "session";
const MAX_FAILED_ATTEMPTS = 5 const MAX_FAILED_ATTEMPTS = 5;
const LOCKOUT_DURATION_MINUTES = 15 const LOCKOUT_DURATION_MINUTES = 15;
export interface SessionUser { export interface SessionUser {
id: string id: string;
username: string username: string;
email: string email: string;
firstName: string firstName: string;
lastName: string lastName: string;
role: string role: string;
avatar: string avatar: string;
} }
export async function hashPassword(password: string): Promise<string> { export async function hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, 12) return bcrypt.hash(password, 12);
} }
export async function comparePassword( export async function comparePassword(
password: string, password: string,
hash: string hash: string,
): Promise<boolean> { ): Promise<boolean> {
return bcrypt.compare(password, hash) return bcrypt.compare(password, hash);
} }
export async function signToken(payload: { userId: string; role: string }) { 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" }) .setProtectedHeader({ alg: "HS256" })
.setExpirationTime("24h") .setExpirationTime("24h")
.setIssuedAt() .setIssuedAt()
.sign(JWT_SECRET) .sign(JWT_SECRET);
} }
export async function verifyToken(token: string) { export async function verifyToken(token: string) {
try { try {
const { payload } = await jwtVerify(token, JWT_SECRET) const { payload } = await jwtVerify(token, JWT_SECRET);
return payload as { userId: string; role: string } return payload as { userId: string; role: string };
} catch { } catch {
return null return null;
} }
} }
@@ -60,9 +60,9 @@ export async function getUserByEmail(email: string) {
JOIN roles r ON r.id = ur.role_id JOIN roles r ON r.id = ur.role_id
WHERE u.email = $1 AND u.deleted_at IS NULL WHERE u.email = $1 AND u.deleted_at IS NULL
LIMIT 1`, LIMIT 1`,
[email.toLowerCase().trim()] [email.toLowerCase().trim()],
) );
return result.rows[0] || null return result.rows[0] || null;
} }
export async function getUserByUsername(username: string) { 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 JOIN roles r ON r.id = ur.role_id
WHERE u.username = $1 AND u.deleted_at IS NULL WHERE u.username = $1 AND u.deleted_at IS NULL
LIMIT 1`, LIMIT 1`,
[username.toLowerCase().trim()] [username.toLowerCase().trim()],
) );
return result.rows[0] || null return result.rows[0] || null;
} }
export async function getUserById(id: string) { 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 JOIN roles r ON r.id = ur.role_id
WHERE u.id = $1 AND u.deleted_at IS NULL WHERE u.id = $1 AND u.deleted_at IS NULL
LIMIT 1`, LIMIT 1`,
[id] [id],
) );
return result.rows[0] || null return result.rows[0] || null;
} }
export async function recordLoginAttempt( export async function recordLoginAttempt(
@@ -102,13 +102,20 @@ export async function recordLoginAttempt(
ipAddress: string, ipAddress: string,
userAgent: string | null, userAgent: string | null,
wasSuccessful: boolean, wasSuccessful: boolean,
failureReason?: string failureReason?: string,
) { ) {
await query( await query(
`INSERT INTO login_attempts (user_id, username_attempted, ip_address, user_agent, was_successful, failure_reason) `INSERT INTO login_attempts (user_id, username_attempted, ip_address, user_agent, was_successful, failure_reason)
VALUES ($1, $2, $3, $4, $5, $6)`, 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) { export async function incrementFailedAttempts(userId: string) {
@@ -121,8 +128,8 @@ export async function incrementFailedAttempts(userId: string) {
ELSE locked_until ELSE locked_until
END END
WHERE id = $1`, WHERE id = $1`,
[userId, MAX_FAILED_ATTEMPTS, LOCKOUT_DURATION_MINUTES] [userId, MAX_FAILED_ATTEMPTS, LOCKOUT_DURATION_MINUTES],
) );
} }
export async function resetFailedAttempts(userId: string) { export async function resetFailedAttempts(userId: string) {
@@ -132,31 +139,38 @@ export async function resetFailedAttempts(userId: string) {
locked_until = NULL, locked_until = NULL,
last_login_at = NOW() last_login_at = NOW()
WHERE id = $1`, WHERE id = $1`,
[userId] [userId],
) );
} }
export async function isAccountLocked(user: { export async function isAccountLocked(user: {
is_locked: boolean is_locked: boolean;
locked_until: Date | null locked_until: Date | null;
}): Promise<{ locked: boolean; reason?: string }> { }): Promise<{ locked: boolean; reason?: string }> {
if (user.is_locked) { 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()) { if (user.locked_until && new Date(user.locked_until) > new Date()) {
const minutes = Math.ceil( const minutes = Math.ceil(
(new Date(user.locked_until).getTime() - Date.now()) / 60000 (new Date(user.locked_until).getTime() - Date.now()) / 60000,
) );
return { return {
locked: true, locked: true,
reason: `Account is temporarily locked due to too many failed attempts. Try again in ${minutes} minute(s).`, 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<string, unknown>): SessionUser { export function mapDbUserToSessionUser(
const roleName = (dbUser.role_name as string).toLowerCase() dbUser: Record<string, unknown>,
): SessionUser {
const roleName = dbUser.role_name as string;
const mappedRole =
roleName === "SUPER_ADMIN" || roleName === "ADMIN" ? "admin" : "sales";
return { return {
id: dbUser.id as string, id: dbUser.id as string,
@@ -166,51 +180,51 @@ export function mapDbUserToSessionUser(dbUser: Record<string, unknown>): Session
lastName: dbUser.last_name as string, lastName: dbUser.last_name as string,
role: roleName, role: roleName,
avatar: `https://ui-avatars.com/api/?name=${encodeURIComponent( 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`, )}&background=1d4ed8&color=fff&size=128`,
} };
} }
export async function createSession(userId: string, role: string) { 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, { cookieStore.set(SESSION_COOKIE, token, {
httpOnly: true, httpOnly: true,
secure: process.env.NODE_ENV === "production", secure: process.env.NODE_ENV === "production",
sameSite: "lax", sameSite: "lax",
path: "/", path: "/",
maxAge: 60 * 60 * 24, // 24 hours maxAge: 60 * 60 * 24, // 24 hours
}) });
return token return token;
} }
export async function destroySession() { export async function destroySession() {
const cookieStore = await cookies() const cookieStore = await cookies();
cookieStore.set(SESSION_COOKIE, "", { cookieStore.set(SESSION_COOKIE, "", {
httpOnly: true, httpOnly: true,
secure: process.env.NODE_ENV === "production", secure: process.env.NODE_ENV === "production",
sameSite: "lax", sameSite: "lax",
path: "/", path: "/",
maxAge: 0, maxAge: 0,
}) });
} }
export async function getSessionUser(): Promise<SessionUser | null> { export async function getSessionUser(): Promise<SessionUser | null> {
try { try {
const cookieStore = await cookies() const cookieStore = await cookies();
const token = cookieStore.get(SESSION_COOKIE)?.value const token = cookieStore.get(SESSION_COOKIE)?.value;
if (!token) return null if (!token) return null;
const payload = await verifyToken(token) const payload = await verifyToken(token);
if (!payload) return null if (!payload) return null;
const dbUser = await getUserById(payload.userId) const dbUser = await getUserById(payload.userId);
if (!dbUser) return null if (!dbUser) return null;
return mapDbUserToSessionUser(dbUser) return mapDbUserToSessionUser(dbUser);
} catch { } catch {
return null return null;
} }
} }
+137
View File
@@ -0,0 +1,137 @@
"use client"
import { createContext, useContext, useState, useCallback, useMemo, ReactNode } from "react"
import { Notification, NotificationType } from "@/types"
import { conversations } from "@/data/chats"
import { leads } from "@/data/leads"
interface NotificationContextValue {
notifications: Notification[]
unreadCount: number
unreadChatCount: number
addNotification: (type: NotificationType, title: string, description: string, link?: string) => void
markAsRead: (id: string) => void
markAllAsRead: () => void
dismiss: (id: string) => void
}
const NotificationContext = createContext<NotificationContextValue | null>(null)
function generateId(): string {
return `notif-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`
}
function seedInitialNotifications(): Notification[] {
const result: Notification[] = []
const recentLeads = leads.slice(0, 5)
recentLeads.forEach((lead) => {
result.push({
id: generateId(),
type: "lead_created",
title: "New Lead Created",
description: `${lead.companyName}${lead.contactName}`,
timestamp: lead.createdAt,
read: false,
link: `/leads/${lead.id}`,
})
})
const statusChanged = leads.filter((l) => l.status !== "open").slice(0, 3)
statusChanged.forEach((lead) => {
result.push({
id: generateId(),
type: "lead_status_changed",
title: "Lead Status Updated",
description: `${lead.companyName} moved to ${lead.status}`,
timestamp: lead.updatedAt,
read: false,
link: `/leads/${lead.id}`,
})
})
const assignedLeads = leads.filter((l) => l.assignedUser).slice(0, 3)
assignedLeads.forEach((lead) => {
result.push({
id: generateId(),
type: "lead_assigned",
title: "Lead Assigned",
description: `${lead.companyName} assigned to ${lead.assignedUser!.name}`,
timestamp: lead.updatedAt,
read: false,
link: `/leads/${lead.id}`,
})
})
result.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime())
return result
}
export function NotificationProvider({ children }: { children: ReactNode }) {
const [notifications, setNotifications] = useState<Notification[]>(seedInitialNotifications)
const unreadChatCount = useMemo(
() => conversations.reduce((sum, c) => sum + c.unread, 0),
[]
)
const unreadCount = useMemo(
() => notifications.filter((n) => !n.read).length,
[notifications]
)
const addNotification = useCallback(
(type: NotificationType, title: string, description: string, link?: string) => {
const notif: Notification = {
id: generateId(),
type,
title,
description,
timestamp: new Date().toISOString(),
read: false,
link,
}
setNotifications((prev) => [notif, ...prev])
},
[]
)
const markAsRead = useCallback((id: string) => {
setNotifications((prev) =>
prev.map((n) => (n.id === id ? { ...n, read: true } : n))
)
}, [])
const markAllAsRead = useCallback(() => {
setNotifications((prev) => prev.map((n) => ({ ...n, read: true })))
}, [])
const dismiss = useCallback((id: string) => {
setNotifications((prev) => prev.filter((n) => n.id !== id))
}, [])
return (
<NotificationContext.Provider
value={{
notifications,
unreadCount,
unreadChatCount,
addNotification,
markAsRead,
markAllAsRead,
dismiss,
}}
>
{children}
</NotificationContext.Provider>
)
}
export function useNotifications(): NotificationContextValue {
const ctx = useContext(NotificationContext)
if (!ctx) {
throw new Error("useNotifications must be used within a NotificationProvider")
}
return ctx
}
+78 -56
View File
@@ -1,77 +1,99 @@
export type LeadStatus = "open" | "contacted" | "pending" | "closed" | "ignored" export type LeadStatus =
export type UserRole = "super_admin" | "admin" | "sales_user" | "developer" | "open"
| "contacted"
| "pending"
| "closed"
| "ignored";
export type UserRole = "admin" | "sales";
export interface User { export interface User {
id: string id: string;
name: string name: string;
email: string email: string;
role: UserRole role: UserRole;
active: boolean active: boolean;
avatar: string avatar: string;
createdAt: string createdAt: string;
} }
export interface Lead { export interface Lead {
id: string id: string;
companyName: string companyName: string;
contactName: string contactName: string;
email: string email: string;
phone: string phone: string;
source: string source: string;
description: string description: string;
status: LeadStatus status: LeadStatus;
assignedUserId: string | null assignedUserId: string | null;
assignedUser: User | null assignedUser: User | null;
createdAt: string createdAt: string;
updatedAt: string updatedAt: string;
} }
export interface Note { export interface Note {
id: string id: string;
leadId: string leadId: string;
userId: string userId: string;
authorName: string authorName: string;
authorAvatar: string authorAvatar: string;
authorRole: UserRole authorRole: UserRole;
note: string note: string;
createdAt: string createdAt: string;
updatedAt: string updatedAt: string;
} }
export interface DashboardStats { export interface DashboardStats {
totalLeads: number totalLeads: number;
openLeads: number openLeads: number;
contactedLeads: number contactedLeads: number;
pendingLeads: number pendingLeads: number;
closedLeads: number closedLeads: number;
ignoredLeads: number ignoredLeads: number;
conversionRate: number conversionRate: number;
leadsPerMonth: { label: string; leads: number; closed: number }[] leadsPerMonth: { label: string; leads: number; closed: number }[];
recentLeads: Lead[] recentLeads: Lead[];
statusDistribution: { name: string; value: number; color: string }[] statusDistribution: { name: string; value: number; color: string }[];
periodLabel: string periodLabel: string;
} }
export interface ColumnFilter { export interface ColumnFilter {
id: string id: string;
value: unknown value: unknown;
}
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;
} }
export interface ChatMessage { export interface ChatMessage {
id: string id: string;
conversationId: string conversationId: string;
senderId: string senderId: string;
senderName: string senderName: string;
senderAvatar: string senderAvatar: string;
content: string content: string;
timestamp: string timestamp: string;
} }
export interface Conversation { export interface Conversation {
id: string id: string;
participants: { id: string; name: string; avatar: string; role: string }[] participants: { id: string; name: string; avatar: string; role: string }[];
lastMessage: string lastMessage: string;
lastMessageTime: string lastMessageTime: string;
unread: number unread: number;
messages: ChatMessage[] messages: ChatMessage[];
} }