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 { 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")
}
+35 -56
View File
@@ -1,54 +1,23 @@
"use client"
"use client";
import { useState, useEffect, useCallback } 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 { Plus, Search, Users as UsersIcon } from "lucide-react"
import { User } from "@/types"
import { toast } from "sonner"
import { useState } from "react";
import { PageHeader } from "@/components/shared/page-header";
import { UsersTable } from "@/components/users/users-table";
import { UserFormDialog } from "@/components/users/user-form-dialog";
import { Button } from "@/components/ui/button";
import { users } from "@/data/users";
import { Plus, Users as UsersIcon } from "lucide-react";
export default function UsersPage() {
const [createOpen, setCreateOpen] = useState(false)
const [users, setUsers] = useState<User[]>([])
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" },
]
const { user } = useUser();
const [createOpen, setCreateOpen] = useState(false);
return (
<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)}>
<Plus className="h-4 w-4" />
Add User
@@ -59,7 +28,9 @@ export default function UsersPage() {
{stats.map((s) => (
<div key={s.label} className="rounded-lg border bg-card p-4">
<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}`} />
</div>
<div>
@@ -71,17 +42,25 @@ export default function UsersPage() {
))}
</div>
<div className="rounded-lg border bg-card">
<div className="flex items-center gap-2 p-4 pb-0">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} placeholder="Search users by name or email..." className="h-9 pl-9" />
</div>
</div>
<UsersTable data={filteredUsers} loading={loading} onUserDeleted={fetchUsers} />
<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>
<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>
)
);
}
+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)
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
@@ -27,8 +27,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(() => {
@@ -49,6 +50,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>
+131 -44
View File
@@ -1,12 +1,13 @@
"use client"
"use client";
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 { 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 {
DropdownMenu,
DropdownMenuContent,
@@ -14,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,
@@ -25,33 +26,62 @@ 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">
{/* Logo */}
<div className="flex items-center gap-2 mr-2 shrink-0">
<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>
{/* 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>
@@ -82,37 +112,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-[#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
</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>
@@ -124,7 +204,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">
@@ -132,7 +214,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 />
@@ -153,5 +240,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)
}
+77 -46
View File
@@ -1,9 +1,9 @@
"use client"
"use client";
import { useState, useEffect } from "react"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import * as z from "zod"
import { useState, useEffect } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod";
import {
Dialog,
DialogContent,
@@ -11,8 +11,8 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
@@ -20,18 +20,18 @@ import {
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form"
import { Input } from "@/components/ui/input"
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Switch } from "@/components/ui/switch"
import { User } from "@/types"
import { toast } from "sonner"
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { User } from "@/types";
import { toast } from "sonner";
const createSchema = z.object({
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"),
role: z.string().min(1, "Role is required"),
active: z.boolean(),
})
type UserFormValues = z.infer<typeof createSchema>
});
type UserFormValues = z.infer<typeof createSchema>;
interface UserFormDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
user?: User | null
onUserCreated?: () => void
open: boolean;
onOpenChange: (open: boolean) => void;
user?: User | null;
onUserCreated?: () => void;
}
export function UserFormDialog({ open, onOpenChange, user, onUserCreated }: UserFormDialogProps) {
const isEditing = !!user
const [submitting, setSubmitting] = useState(false)
export function UserFormDialog({
open,
onOpenChange,
user,
onUserCreated,
}: UserFormDialogProps) {
const isEditing = !!user;
const [submitting, setSubmitting] = useState(false);
const form = useForm<UserFormValues>({
resolver: zodResolver(createSchema),
@@ -65,7 +68,7 @@ export function UserFormDialog({ open, onOpenChange, user, onUserCreated }: User
role: "sales_user",
active: true,
},
})
});
useEffect(() => {
if (user) {
@@ -75,32 +78,38 @@ export function UserFormDialog({ open, onOpenChange, user, onUserCreated }: User
password: "",
role: user.role,
active: user.active,
})
});
} 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) {
setSubmitting(true)
setSubmitting(true);
try {
const res = await fetch("/api/users", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(values),
})
const data = await res.json()
});
const data = await res.json();
if (!res.ok) {
toast.error(data.error || "Failed to create user")
return
toast.error(data.error || "Failed to create user");
return;
}
toast.success("User created")
onOpenChange(false)
onUserCreated?.()
toast.success("User created");
onOpenChange(false);
onUserCreated?.();
} catch {
toast.error("Failed to create user")
toast.error("Failed to create user");
} finally {
setSubmitting(false)
setSubmitting(false);
}
}
@@ -137,7 +146,11 @@ export function UserFormDialog({ open, onOpenChange, user, onUserCreated }: User
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder="john@coastit.co.za" type="email" {...field} />
<Input
placeholder="john@coastit.co.za"
type="email"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
@@ -149,7 +162,10 @@ export function UserFormDialog({ open, onOpenChange, user, onUserCreated }: User
render={({ field }) => (
<FormItem>
<FormLabel>Role</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select role" />
@@ -158,8 +174,7 @@ export function UserFormDialog({ open, onOpenChange, user, onUserCreated }: User
<SelectContent>
<SelectItem value="super_admin">Super Admin</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
<SelectItem value="sales_user">Sales</SelectItem>
<SelectItem value="developer">Developer</SelectItem>
<SelectItem value="sales">Sales</SelectItem>
</SelectContent>
</Select>
<FormMessage />
@@ -173,7 +188,15 @@ export function UserFormDialog({ open, onOpenChange, user, onUserCreated }: User
<FormItem>
<FormLabel>Password</FormLabel>
<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>
<FormMessage />
</FormItem>
@@ -200,16 +223,24 @@ export function UserFormDialog({ open, onOpenChange, user, onUserCreated }: User
)}
/>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
>
Cancel
</Button>
<Button type="submit" disabled={submitting}>
{submitting ? "Saving..." : isEditing ? "Save Changes" : "Create User"}
{submitting
? "Saving..."
: isEditing
? "Save Changes"
: "Create User"}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
)
);
}
+9
View File
@@ -1,6 +1,15 @@
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 -65
View File
@@ -1,35 +1,35 @@
import { SignJWT, jwtVerify } from "jose"
import bcrypt from "bcryptjs"
import { query } from "@/lib/db"
import { cookies } from "next/headers"
import { SignJWT, jwtVerify } from "jose";
import bcrypt from "bcryptjs";
import { query } from "@/lib/db";
import { cookies } from "next/headers";
const JWT_SECRET = new TextEncoder().encode(
process.env.JWT_SECRET || "fallback-dev-secret-do-not-use-in-production"
)
process.env.JWT_SECRET || "fallback-dev-secret-do-not-use-in-production",
);
const SESSION_COOKIE = "session"
const MAX_FAILED_ATTEMPTS = 5
const LOCKOUT_DURATION_MINUTES = 15
const SESSION_COOKIE = "session";
const MAX_FAILED_ATTEMPTS = 5;
const LOCKOUT_DURATION_MINUTES = 15;
export interface SessionUser {
id: string
username: string
email: string
firstName: string
lastName: string
role: string
avatar: string
id: string;
username: string;
email: string;
firstName: string;
lastName: string;
role: string;
avatar: string;
}
export async function hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, 12)
return bcrypt.hash(password, 12);
}
export async function comparePassword(
password: string,
hash: string
hash: string,
): Promise<boolean> {
return bcrypt.compare(password, hash)
return bcrypt.compare(password, hash);
}
export async function signToken(payload: { userId: string; role: string }) {
@@ -37,15 +37,15 @@ export async function signToken(payload: { userId: string; role: string }) {
.setProtectedHeader({ alg: "HS256" })
.setExpirationTime("24h")
.setIssuedAt()
.sign(JWT_SECRET)
.sign(JWT_SECRET);
}
export async function verifyToken(token: string) {
try {
const { payload } = await jwtVerify(token, JWT_SECRET)
return payload as { userId: string; role: string }
const { payload } = await jwtVerify(token, JWT_SECRET);
return payload as { userId: string; role: string };
} catch {
return null
return null;
}
}
@@ -60,9 +60,9 @@ export async function getUserByEmail(email: string) {
JOIN roles r ON r.id = ur.role_id
WHERE u.email = $1 AND u.deleted_at IS NULL
LIMIT 1`,
[email.toLowerCase().trim()]
)
return result.rows[0] || null
[email.toLowerCase().trim()],
);
return result.rows[0] || null;
}
export async function getUserByUsername(username: string) {
@@ -76,9 +76,9 @@ export async function getUserByUsername(username: string) {
JOIN roles r ON r.id = ur.role_id
WHERE u.username = $1 AND u.deleted_at IS NULL
LIMIT 1`,
[username.toLowerCase().trim()]
)
return result.rows[0] || null
[username.toLowerCase().trim()],
);
return result.rows[0] || null;
}
export async function getUserById(id: string) {
@@ -91,9 +91,9 @@ export async function getUserById(id: string) {
JOIN roles r ON r.id = ur.role_id
WHERE u.id = $1 AND u.deleted_at IS NULL
LIMIT 1`,
[id]
)
return result.rows[0] || null
[id],
);
return result.rows[0] || null;
}
export async function recordLoginAttempt(
@@ -102,13 +102,20 @@ export async function recordLoginAttempt(
ipAddress: string,
userAgent: string | null,
wasSuccessful: boolean,
failureReason?: string
failureReason?: string,
) {
await query(
`INSERT INTO login_attempts (user_id, username_attempted, ip_address, user_agent, was_successful, failure_reason)
VALUES ($1, $2, $3, $4, $5, $6)`,
[userId, usernameAttempted, ipAddress, userAgent, wasSuccessful, failureReason || null]
)
[
userId,
usernameAttempted,
ipAddress,
userAgent,
wasSuccessful,
failureReason || null,
],
);
}
export async function incrementFailedAttempts(userId: string) {
@@ -121,8 +128,8 @@ export async function incrementFailedAttempts(userId: string) {
ELSE locked_until
END
WHERE id = $1`,
[userId, MAX_FAILED_ATTEMPTS, LOCKOUT_DURATION_MINUTES]
)
[userId, MAX_FAILED_ATTEMPTS, LOCKOUT_DURATION_MINUTES],
);
}
export async function resetFailedAttempts(userId: string) {
@@ -132,31 +139,38 @@ export async function resetFailedAttempts(userId: string) {
locked_until = NULL,
last_login_at = NOW()
WHERE id = $1`,
[userId]
)
[userId],
);
}
export async function isAccountLocked(user: {
is_locked: boolean
locked_until: Date | null
is_locked: boolean;
locked_until: Date | null;
}): Promise<{ locked: boolean; reason?: string }> {
if (user.is_locked) {
return { locked: true, reason: "Account has been locked by an administrator." }
return {
locked: true,
reason: "Account has been locked by an administrator.",
};
}
if (user.locked_until && new Date(user.locked_until) > new Date()) {
const minutes = Math.ceil(
(new Date(user.locked_until).getTime() - Date.now()) / 60000
)
(new Date(user.locked_until).getTime() - Date.now()) / 60000,
);
return {
locked: true,
reason: `Account is temporarily locked due to too many failed attempts. Try again in ${minutes} minute(s).`,
}
};
}
return { locked: false }
return { locked: false };
}
export function mapDbUserToSessionUser(dbUser: Record<string, unknown>): SessionUser {
const roleName = (dbUser.role_name as string).toLowerCase()
export function mapDbUserToSessionUser(
dbUser: Record<string, unknown>,
): SessionUser {
const roleName = dbUser.role_name as string;
const mappedRole =
roleName === "SUPER_ADMIN" || roleName === "ADMIN" ? "admin" : "sales";
return {
id: dbUser.id as string,
@@ -166,51 +180,51 @@ export function mapDbUserToSessionUser(dbUser: Record<string, unknown>): Session
lastName: dbUser.last_name as string,
role: roleName,
avatar: `https://ui-avatars.com/api/?name=${encodeURIComponent(
`${dbUser.first_name}+${dbUser.last_name}`
`${dbUser.first_name}+${dbUser.last_name}`,
)}&background=1d4ed8&color=fff&size=128`,
}
};
}
export async function createSession(userId: string, role: string) {
const token = await signToken({ userId, role })
const token = await signToken({ userId, role });
const cookieStore = await cookies()
const cookieStore = await cookies();
cookieStore.set(SESSION_COOKIE, token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge: 60 * 60 * 24, // 24 hours
})
});
return token
return token;
}
export async function destroySession() {
const cookieStore = await cookies()
const cookieStore = await cookies();
cookieStore.set(SESSION_COOKIE, "", {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge: 0,
})
});
}
export async function getSessionUser(): Promise<SessionUser | null> {
try {
const cookieStore = await cookies()
const token = cookieStore.get(SESSION_COOKIE)?.value
if (!token) return null
const cookieStore = await cookies();
const token = cookieStore.get(SESSION_COOKIE)?.value;
if (!token) return null;
const payload = await verifyToken(token)
if (!payload) return null
const payload = await verifyToken(token);
if (!payload) return null;
const dbUser = await getUserById(payload.userId)
if (!dbUser) return null
const dbUser = await getUserById(payload.userId);
if (!dbUser) return null;
return mapDbUserToSessionUser(dbUser)
return mapDbUserToSessionUser(dbUser);
} catch {
return null
return null;
}
}
+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 UserRole = "super_admin" | "admin" | "sales_user" | "developer"
export type LeadStatus =
| "open"
| "contacted"
| "pending"
| "closed"
| "ignored";
export type UserRole = "admin" | "sales";
export interface User {
id: string
name: string
email: string
role: UserRole
active: boolean
avatar: string
createdAt: string
id: string;
name: string;
email: string;
role: UserRole;
active: boolean;
avatar: string;
createdAt: string;
}
export interface Lead {
id: string
companyName: string
contactName: string
email: string
phone: string
source: string
description: string
status: LeadStatus
assignedUserId: string | null
assignedUser: User | null
createdAt: string
updatedAt: string
id: string;
companyName: string;
contactName: string;
email: string;
phone: string;
source: string;
description: string;
status: LeadStatus;
assignedUserId: string | null;
assignedUser: User | null;
createdAt: string;
updatedAt: string;
}
export interface Note {
id: string
leadId: string
userId: string
authorName: string
authorAvatar: string
authorRole: UserRole
note: string
createdAt: string
updatedAt: string
id: string;
leadId: string;
userId: string;
authorName: string;
authorAvatar: string;
authorRole: UserRole;
note: string;
createdAt: string;
updatedAt: string;
}
export interface DashboardStats {
totalLeads: number
openLeads: number
contactedLeads: number
pendingLeads: number
closedLeads: number
ignoredLeads: number
conversionRate: number
leadsPerMonth: { label: string; leads: number; closed: number }[]
recentLeads: Lead[]
statusDistribution: { name: string; value: number; color: string }[]
periodLabel: string
totalLeads: number;
openLeads: number;
contactedLeads: number;
pendingLeads: number;
closedLeads: number;
ignoredLeads: number;
conversionRate: number;
leadsPerMonth: { label: string; leads: number; closed: number }[];
recentLeads: Lead[];
statusDistribution: { name: string; value: number; color: string }[];
periodLabel: string;
}
export interface ColumnFilter {
id: string
value: unknown
id: string;
value: unknown;
}
export type NotificationType =
| "lead_created"
| "lead_status_changed"
| "lead_assigned"
| "chat_message"
| "note_added";
export interface Notification {
id: string;
type: NotificationType;
title: string;
description: string;
timestamp: string;
read: boolean;
link?: string;
}
export interface ChatMessage {
id: string
conversationId: string
senderId: string
senderName: string
senderAvatar: string
content: string
timestamp: string
id: string;
conversationId: string;
senderId: string;
senderName: string;
senderAvatar: string;
content: string;
timestamp: string;
}
export interface Conversation {
id: string
participants: { id: string; name: string; avatar: string; role: string }[]
lastMessage: string
lastMessageTime: string
unread: number
messages: ChatMessage[]
id: string;
participants: { id: string; name: string; avatar: string; role: string }[];
lastMessage: string;
lastMessageTime: string;
unread: number;
messages: ChatMessage[];
}