I added functioonality to the notifications
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user