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