I added functioonality to the notifications

This commit is contained in:
2026-06-18 16:43:26 +02:00
parent a0d8420a2f
commit 1e4b8df8dd
13 changed files with 390 additions and 64 deletions
+4 -1
View File
@@ -2,6 +2,7 @@
import { AppShell } from "@/components/layout/app-shell"
import { UserProvider, useUser } from "@/providers/user-provider"
import { NotificationProvider } from "@/providers/notification-provider"
import { Loader2 } from "lucide-react"
function DashboardContent({ children }: { children: React.ReactNode }) {
@@ -27,7 +28,9 @@ export default function DashboardLayout({
}) {
return (
<UserProvider>
<DashboardContent>{children}</DashboardContent>
<NotificationProvider>
<DashboardContent>{children}</DashboardContent>
</NotificationProvider>
</UserProvider>
)
}
+8
View File
@@ -29,6 +29,7 @@ import { ArrowLeft } from "lucide-react"
import { Lead, LeadStatus } from "@/types"
import { leads } from "@/data/leads"
import { users } from "@/data/users"
import { useNotifications } from "@/providers/notification-provider"
import { LEAD_SOURCES, LEAD_STATUSES } from "@/lib/constants"
const leadFormSchema = z.object({
@@ -46,6 +47,7 @@ type LeadFormValues = z.infer<typeof leadFormSchema>
export default function CreateLeadPage() {
const router = useRouter()
const { addNotification } = useNotifications()
const [saving, setSaving] = useState(false)
const form = useForm<LeadFormValues>({
@@ -84,6 +86,12 @@ export default function CreateLeadPage() {
}
leads.unshift(newLead)
addNotification(
"lead_created",
"New Lead Created",
`${newLead.companyName}${newLead.contactName}`,
`/leads/${newLead.id}`
)
router.push("/leads")
}
+33 -7
View File
@@ -1,15 +1,29 @@
"use client"
import { useState } from "react"
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 { Plus, Users as UsersIcon } from "lucide-react"
import { useUser } from "@/providers/user-provider"
import { Plus, Search, 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])
return (
<div className="space-y-4">
@@ -17,10 +31,12 @@ export default function UsersPage() {
title="Users"
description="Manage team members and their roles"
>
<Button className="gap-2" onClick={() => setCreateOpen(true)}>
<Plus className="h-4 w-4" />
Add User
</Button>
{user?.role === "super_admin" && (
<Button className="gap-2" onClick={() => setCreateOpen(true)}>
<Plus className="h-4 w-4" />
Add User
</Button>
)}
</PageHeader>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4 mb-6">
@@ -70,8 +86,18 @@ export default function UsersPage() {
</div>
</div>
<div className="relative">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Search by name or email..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="h-9 pl-9"
/>
</div>
<div className="rounded-lg border bg-card">
<UsersTable data={users} />
<UsersTable data={filteredUsers} />
</div>
<UserFormDialog open={createOpen} onOpenChange={setCreateOpen} />
@@ -36,6 +36,21 @@ export function LeadStatusChart({ data }: LeadStatusChartProps) {
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
const segs = data.map((d) => {
const start = cum
@@ -28,8 +28,9 @@ 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)))
return Math.ceil(max / 7) * 7
return Math.max(Math.ceil(max / 7) * 7, 1)
}, [data])
const ticks = useMemo(() => {
@@ -50,6 +51,21 @@ export function LeadsPerMonthChart({ data }: LeadsPerMonthChartProps) {
const totalClosed = data.reduce((s, d) => s + d.closed, 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 (
<motion.div
initial={{ opacity: 0, y: 20 }}
+4 -5
View File
@@ -20,9 +20,7 @@ import {
} from "lucide-react"
import { COMPANY_NAME } from "@/lib/constants"
import { useUser } from "@/providers/user-provider"
import { conversations as conversationsData } from "@/data/chats"
const totalUnread = conversationsData.reduce((sum, c) => sum + c.unread, 0)
import { useNotifications } from "@/providers/notification-provider"
const navItems = [
{ href: "/dashboard", label: "Dashboard", icon: LayoutDashboard },
@@ -42,6 +40,7 @@ interface SidebarProps {
export function Sidebar({ collapsed, onToggle, mobileOpen, onMobileClose }: SidebarProps) {
const pathname = usePathname()
const { user } = useUser()
const { unreadChatCount } = useNotifications()
if (!user) return null
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.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">
{totalUnread}
{unreadChatCount}
</span>
)}
</Link>
+128 -45
View File
@@ -1,13 +1,13 @@
"use client"
"use client";
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 { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { useTheme } from "next-themes";
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,8 +15,8 @@ import {
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Badge } from "@/components/ui/badge"
} from "@/components/ui/dropdown-menu";
import { Badge } from "@/components/ui/badge";
import {
Search,
Bell,
@@ -26,27 +26,53 @@ import {
LogOut,
User,
Settings,
} from "lucide-react"
CheckCheck,
Trash2,
} from "lucide-react";
interface TopbarProps {
onMenuClick: () => void
onMenuClick: () => void;
}
export function Topbar({ onMenuClick }: TopbarProps) {
const router = useRouter()
const { theme, setTheme } = useTheme()
const { user, logout } = useUser()
const [mounted, setMounted] = useState(false)
const [searchOpen, setSearchOpen] = useState(false)
const router = useRouter();
const { theme, setTheme } = useTheme();
const { user, logout } = useUser();
const { notifications, unreadCount, markAsRead, markAllAsRead, dismiss } = useNotifications();
const [mounted, setMounted] = useState(false);
const [searchOpen, setSearchOpen] = useState(false);
useEffect(() => { setMounted(true) }, [])
if (!user) return null
const initials = user.name.split(" ").map((n: string) => n[0]).join("").toUpperCase()
useEffect(() => {
setMounted(true);
}, []);
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 (
<header className="sticky top-0 z-20 flex h-16 items-center gap-4 border-b bg-background px-4 lg:px-6">
{/* 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" />
</Button>
@@ -77,37 +103,87 @@ export function Topbar({ onMenuClick }: TopbarProps) {
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
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>
{/* Notifications */}
<DropdownMenu>
<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" />
<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
</span>
{unreadCount > 0 && (
<span className="absolute -right-0.5 -top-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-medium text-primary-foreground">
{unreadCount}
</span>
)}
</Button>
</DropdownMenuTrigger>
<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 />
<div className="space-y-1 p-2">
{[
{ text: "New lead assigned to you", time: "5m ago" },
{ text: "Lead status updated to Closed", time: "1h ago" },
{ text: "Note added to Brightwave Studios", time: "3h ago" },
].map((n, i) => (
<div key={i} className="flex items-start gap-3 rounded-lg p-2 hover:bg-muted/50">
<div className="h-2 w-2 mt-2 rounded-full bg-primary shrink-0" />
<div className="flex-1 space-y-0.5">
<p className="text-sm">{n.text}</p>
<p className="text-xs text-muted-foreground">{n.time}</p>
{notifications.length === 0 ? (
<div className="py-6 text-center text-sm text-muted-foreground">
No notifications
</div>
) : (
<div className="max-h-[360px] space-y-1 overflow-y-auto p-2">
{notifications.slice(0, 20).map((n) => (
<div
key={n.id}
role="button"
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>
)}
</DropdownMenuContent>
</DropdownMenu>
@@ -119,7 +195,9 @@ export function Topbar({ onMenuClick }: TopbarProps) {
<AvatarImage src={user.avatar} />
<AvatarFallback>{initials}</AvatarFallback>
</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>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
@@ -127,7 +205,12 @@ export function Topbar({ onMenuClick }: TopbarProps) {
<div className="flex flex-col space-y-1">
<p className="text-sm font-medium">{user.name}</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>
</DropdownMenuLabel>
<DropdownMenuSeparator />
@@ -148,5 +231,5 @@ export function Topbar({ onMenuClick }: TopbarProps) {
</DropdownMenu>
</div>
</header>
)
);
}
+11
View File
@@ -33,6 +33,7 @@ import {
import { Lead, LeadStatus, User } from "@/types"
import { LEAD_SOURCES, LEAD_STATUSES } from "@/lib/constants"
import { users } from "@/data/users"
import { useNotifications } from "@/providers/notification-provider"
const leadFormSchema = z.object({
companyName: z.string().min(1, "Company name is required"),
@@ -55,6 +56,7 @@ interface LeadFormDialogProps {
export function LeadFormDialog({ open, onOpenChange, lead }: LeadFormDialogProps) {
const isEditing = !!lead
const { addNotification } = useNotifications()
const form = useForm<LeadFormValues>({
resolver: zodResolver(leadFormSchema),
@@ -101,6 +103,15 @@ export function LeadFormDialog({ open, onOpenChange, lead }: LeadFormDialogProps
...values,
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)
onOpenChange(false)
}
@@ -130,7 +130,9 @@ export function UserFormDialog({ open, onOpenChange, user }: UserFormDialogProps
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="super_admin">Super Admin</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
<SelectItem value="developer">Developer</SelectItem>
<SelectItem value="sales">Sales</SelectItem>
</SelectContent>
</Select>
+11 -2
View File
@@ -1,6 +1,15 @@
import { User } from "@/types"
import { User, UserRole } from "@/types"
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",
name: "Sarah Chen",
@@ -79,7 +88,7 @@ export function getUserById(id: string): User | undefined {
return users.find((u) => u.id === id)
}
export function getUsersByRole(role: "admin" | "sales"): User[] {
export function getUsersByRole(role: UserRole): User[] {
return users.filter((u) => u.role === role)
}
+7 -2
View File
@@ -157,8 +157,13 @@ export async function isAccountLocked(user: {
export function mapDbUserToSessionUser(dbUser: Record<string, unknown>): SessionUser {
const roleName = dbUser.role_name as string
const mappedRole =
roleName === "SUPER_ADMIN" || roleName === "ADMIN" ? "admin" : "sales"
const roleMap: Record<string, string> = {
SUPER_ADMIN: "super_admin",
ADMIN: "admin",
DEVELOPER: "developer",
SALES_USER: "sales",
}
const mappedRole = roleMap[roleName] || "sales"
return {
id: dbUser.id as string,
+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
}
+13 -1
View File
@@ -1,5 +1,5 @@
export type LeadStatus = "open" | "contacted" | "pending" | "closed" | "ignored"
export type UserRole = "admin" | "sales"
export type UserRole = "super_admin" | "admin" | "developer" | "sales"
export interface User {
id: string
@@ -57,6 +57,18 @@ export interface ColumnFilter {
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 {
id: string
conversationId: string