From 1e4b8df8dd728aeb526b1e5f6476a03158de1662 Mon Sep 17 00:00:00 2001 From: caitlin Date: Thu, 18 Jun 2026 16:43:26 +0200 Subject: [PATCH] I added functioonality to the notifications --- src/app/(dashboard)/layout.tsx | 5 +- src/app/(dashboard)/leads/new/page.tsx | 8 + src/app/(dashboard)/users/page.tsx | 40 +++- .../dashboard/lead-status-chart.tsx | 15 ++ .../dashboard/leads-per-month-chart.tsx | 18 +- src/components/layout/sidebar.tsx | 9 +- src/components/layout/topbar.tsx | 173 +++++++++++++----- src/components/leads/lead-form-dialog.tsx | 11 ++ src/components/users/user-form-dialog.tsx | 2 + src/data/users.ts | 13 +- src/lib/auth.ts | 9 +- src/providers/notification-provider.tsx | 137 ++++++++++++++ src/types/index.ts | 14 +- 13 files changed, 390 insertions(+), 64 deletions(-) create mode 100644 src/providers/notification-provider.tsx diff --git a/src/app/(dashboard)/layout.tsx b/src/app/(dashboard)/layout.tsx index 2a73b5a..94b1d8d 100644 --- a/src/app/(dashboard)/layout.tsx +++ b/src/app/(dashboard)/layout.tsx @@ -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 ( - {children} + + {children} + ) } diff --git a/src/app/(dashboard)/leads/new/page.tsx b/src/app/(dashboard)/leads/new/page.tsx index 4db1ae7..6b926c8 100644 --- a/src/app/(dashboard)/leads/new/page.tsx +++ b/src/app/(dashboard)/leads/new/page.tsx @@ -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 export default function CreateLeadPage() { const router = useRouter() + const { addNotification } = useNotifications() const [saving, setSaving] = useState(false) const form = useForm({ @@ -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") } diff --git a/src/app/(dashboard)/users/page.tsx b/src/app/(dashboard)/users/page.tsx index c218263..1d2ee14 100644 --- a/src/app/(dashboard)/users/page.tsx +++ b/src/app/(dashboard)/users/page.tsx @@ -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 (
@@ -17,10 +31,12 @@ export default function UsersPage() { title="Users" description="Manage team members and their roles" > - + {user?.role === "super_admin" && ( + + )}
@@ -70,8 +86,18 @@ export default function UsersPage() {
+
+ + setSearch(e.target.value)} + className="h-9 pl-9" + /> +
+
- +
diff --git a/src/components/dashboard/lead-status-chart.tsx b/src/components/dashboard/lead-status-chart.tsx index 12ec99f..30d2780 100644 --- a/src/components/dashboard/lead-status-chart.tsx +++ b/src/components/dashboard/lead-status-chart.tsx @@ -36,6 +36,21 @@ export function LeadStatusChart({ data }: LeadStatusChartProps) { const total = data.reduce((s, d) => s + d.value, 0) + if (total === 0) { + return ( + + + Lead Status + + +
+ No data for this period +
+
+
+ ) + } + let cum = 0 const segs = data.map((d) => { const start = cum diff --git a/src/components/dashboard/leads-per-month-chart.tsx b/src/components/dashboard/leads-per-month-chart.tsx index b245d36..c758339 100644 --- a/src/components/dashboard/leads-per-month-chart.tsx +++ b/src/components/dashboard/leads-per-month-chart.tsx @@ -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 ( + + + Leads Per Month + + +
+ No data for this period +
+
+
+ ) + } + return ( 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.label === "Chats" && totalUnread > 0 && ( + {item.label === "Chats" && unreadChatCount > 0 && ( - {totalUnread} + {unreadChatCount} )} diff --git a/src/components/layout/topbar.tsx b/src/components/layout/topbar.tsx index 954a881..7c7d0f1 100644 --- a/src/components/layout/topbar.tsx +++ b/src/components/layout/topbar.tsx @@ -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 (
{/* Mobile menu button */} - @@ -77,37 +103,87 @@ export function Topbar({ onMenuClick }: TopbarProps) { onClick={() => setTheme(theme === "dark" ? "light" : "dark")} className="text-muted-foreground" > - {mounted ? (theme === "dark" ? : ) :
} + {mounted ? ( + theme === "dark" ? ( + + ) : ( + + ) + ) : ( +
+ )} {/* Notifications */} - - Notifications + + Notifications + {notifications.length > 0 && ( + + )} + -
- {[ - { 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) => ( -
-
-
-

{n.text}

-

{n.time}

+ {notifications.length === 0 ? ( +
+ No notifications +
+ ) : ( +
+ {notifications.slice(0, 20).map((n) => ( +
{ + 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" : ""}`} + > +
+
+

{n.title}

+

{n.description}

+

+ {formatTimeAgo(n.timestamp)} +

+
+
-
- ))} -
+ ))} +
+ )} @@ -119,7 +195,9 @@ export function Topbar({ onMenuClick }: TopbarProps) { {initials} - {user.name} + + {user.name} + @@ -127,7 +205,12 @@ export function Topbar({ onMenuClick }: TopbarProps) {

{user.name}

{user.email}

- {user.role} + + {user.role} +
@@ -148,5 +231,5 @@ export function Topbar({ onMenuClick }: TopbarProps) {
- ) + ); } diff --git a/src/components/leads/lead-form-dialog.tsx b/src/components/leads/lead-form-dialog.tsx index a7c3579..afa6081 100644 --- a/src/components/leads/lead-form-dialog.tsx +++ b/src/components/leads/lead-form-dialog.tsx @@ -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({ 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) } diff --git a/src/components/users/user-form-dialog.tsx b/src/components/users/user-form-dialog.tsx index 5965dca..b3ddcdc 100644 --- a/src/components/users/user-form-dialog.tsx +++ b/src/components/users/user-form-dialog.tsx @@ -130,7 +130,9 @@ export function UserFormDialog({ open, onOpenChange, user }: UserFormDialogProps + Super Admin Admin + Developer Sales diff --git a/src/data/users.ts b/src/data/users.ts index 3f244a4..227d07f 100644 --- a/src/data/users.ts +++ b/src/data/users.ts @@ -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) } diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 5fe6525..7814960 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -157,8 +157,13 @@ export async function isAccountLocked(user: { export function mapDbUserToSessionUser(dbUser: Record): SessionUser { const roleName = dbUser.role_name as string - const mappedRole = - roleName === "SUPER_ADMIN" || roleName === "ADMIN" ? "admin" : "sales" + const roleMap: Record = { + SUPER_ADMIN: "super_admin", + ADMIN: "admin", + DEVELOPER: "developer", + SALES_USER: "sales", + } + const mappedRole = roleMap[roleName] || "sales" return { id: dbUser.id as string, diff --git a/src/providers/notification-provider.tsx b/src/providers/notification-provider.tsx new file mode 100644 index 0000000..2b33806 --- /dev/null +++ b/src/providers/notification-provider.tsx @@ -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(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(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 ( + + {children} + + ) +} + +export function useNotifications(): NotificationContextValue { + const ctx = useContext(NotificationContext) + if (!ctx) { + throw new Error("useNotifications must be used within a NotificationProvider") + } + return ctx +} diff --git a/src/types/index.ts b/src/types/index.ts index a9b5f1a..6895ed7 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -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