new updates
This commit is contained in:
@@ -38,6 +38,7 @@ import {
|
||||
PackageCheck,
|
||||
PackageSearch,
|
||||
PackageX,
|
||||
Percent,
|
||||
PlayCircle,
|
||||
PieChart,
|
||||
Receipt,
|
||||
@@ -96,7 +97,9 @@ const navItems: {
|
||||
children: [
|
||||
{ title: "Item", code: "products.item", href: "/dashboard/products", icon: Boxes },
|
||||
{ title: "Category", code: "products.category", href: "/dashboard/products/categories", icon: ListTree },
|
||||
{ title: "Brand/UOM", code: "products.brand", href: "/dashboard/products/brands", icon: Tag, altCodes: ["products.uom"] },
|
||||
{ title: "Brand", code: "products.brand", href: "/dashboard/products/brands", icon: Tag },
|
||||
{ title: "UOM", code: "products.uom", href: "/dashboard/products/uoms", icon: Ruler },
|
||||
{ title: "Tax Rates", code: "products.tax-rates", href: "/dashboard/products/tax-rates", icon: Percent },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { usePathname, useRouter } from "next/navigation"
|
||||
import { ArrowLeft, Bell, Building2, LogOut, Settings, User } from "lucide-react"
|
||||
import { ArrowLeft, Bell, Boxes, Building2, FileText, LogOut, Receipt, Search, Settings, User } from "lucide-react"
|
||||
|
||||
import { authApi } from "@/lib/api/auth"
|
||||
import { searchApi } from "@/lib/api/search"
|
||||
import { notificationsApi } from "@/lib/api/notifications"
|
||||
import { clearStoredUser, displayName, getStoredUser } from "@/lib/auth-session"
|
||||
import { branchesHrmApi } from "@/lib/api/hrm-masters"
|
||||
import { AuthUser } from "@/types/auth"
|
||||
import { Branch } from "@/types/hrm"
|
||||
import { NotificationItem } from "@/types/notification-feed"
|
||||
import { GlobalSearchResult, GlobalSearchResultType } from "@/types/search"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useAuth } from "@/components/auth/AuthProvider"
|
||||
import { ThemeToggle } from "@/components/theme-toggle"
|
||||
@@ -125,44 +129,24 @@ function titleFromPath(pathname: string) {
|
||||
return segment.charAt(0).toUpperCase() + segment.slice(1)
|
||||
}
|
||||
|
||||
interface Notification {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
time: string
|
||||
unread: boolean
|
||||
const SEARCH_TYPE_META: Record<GlobalSearchResultType, { label: string; icon: typeof Boxes; href: (id: number) => string }> = {
|
||||
Item: { label: "Items", icon: Boxes, href: (id) => `/dashboard/products/${id}` },
|
||||
PurchaseOrder: { label: "Purchase Orders", icon: FileText, href: (id) => `/dashboard/procurement/purchase-orders/${id}` },
|
||||
Invoice: { label: "Invoices", icon: Receipt, href: (id) => `/dashboard/sales/invoices/${id}` },
|
||||
Employee: { label: "Employees", icon: User, href: (id) => `/dashboard/hrm/employees/${id}` },
|
||||
}
|
||||
|
||||
const initialNotifications: Notification[] = [
|
||||
{
|
||||
id: "1",
|
||||
title: "New order received",
|
||||
description: "Order #3210 placed by John Martinez",
|
||||
time: "2m ago",
|
||||
unread: true,
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
title: "Low stock alert",
|
||||
description: "SKU-2291 has dropped below its reorder threshold",
|
||||
time: "1h ago",
|
||||
unread: true,
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
title: "Payment failed",
|
||||
description: "Order #3207 payment was declined",
|
||||
time: "3h ago",
|
||||
unread: false,
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
title: "New customer",
|
||||
description: "Grace Kim just created an account",
|
||||
time: "1d ago",
|
||||
unread: false,
|
||||
},
|
||||
]
|
||||
/** Relative time for a notification timestamp: "2m ago", "3h ago", "1d ago". */
|
||||
function relativeTime(iso: string): string {
|
||||
const diffMs = Date.now() - new Date(iso).getTime()
|
||||
const minutes = Math.floor(diffMs / 60000)
|
||||
if (minutes < 1) return "just now"
|
||||
if (minutes < 60) return `${minutes}m ago`
|
||||
const hours = Math.floor(minutes / 60)
|
||||
if (hours < 24) return `${hours}h ago`
|
||||
const days = Math.floor(hours / 24)
|
||||
return `${days}d ago`
|
||||
}
|
||||
|
||||
export function Header() {
|
||||
const pathname = usePathname() || "/"
|
||||
@@ -171,8 +155,24 @@ export function Header() {
|
||||
const showBackButton = pathname !== "/dashboard"
|
||||
const { isAdmin, branchName, activeBranchId, activeBranchName, setActiveBranch, branchingEnabled } = useAuth()
|
||||
const branchLabel = isAdmin ? (activeBranchName ?? "All Branches") : branchName
|
||||
const [notifications, setNotifications] = useState(initialNotifications)
|
||||
const unreadCount = notifications.filter((n) => n.unread).length
|
||||
const [notifications, setNotifications] = useState<NotificationItem[]>([])
|
||||
const [unreadCount, setUnreadCount] = useState(0)
|
||||
|
||||
const loadNotifications = () =>
|
||||
notificationsApi
|
||||
.getFeed()
|
||||
.then((feed) => {
|
||||
setNotifications(feed.notifications)
|
||||
setUnreadCount(feed.unreadCount)
|
||||
})
|
||||
.catch(() => {
|
||||
setNotifications([])
|
||||
setUnreadCount(0)
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
loadNotifications()
|
||||
}, [])
|
||||
|
||||
const [branches, setBranches] = useState<Branch[]>([])
|
||||
useEffect(() => {
|
||||
@@ -197,8 +197,53 @@ export function Header() {
|
||||
setUser(getStoredUser())
|
||||
}, [])
|
||||
|
||||
const markAllAsRead = () =>
|
||||
setNotifications((prev) => prev.map((n) => ({ ...n, unread: false })))
|
||||
// Global search bar: jump to an Item, PO, Invoice or Employee by number or name.
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
const [searchResults, setSearchResults] = useState<GlobalSearchResult[]>([])
|
||||
const [searchOpen, setSearchOpen] = useState(false)
|
||||
const searchBoxRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const term = searchQuery.trim()
|
||||
if (term.length < 2) return
|
||||
|
||||
const handle = setTimeout(() => {
|
||||
searchApi
|
||||
.search(term)
|
||||
.then((r) => setSearchResults(r))
|
||||
.catch(() => setSearchResults([]))
|
||||
}, 200)
|
||||
return () => clearTimeout(handle)
|
||||
}, [searchQuery])
|
||||
|
||||
useEffect(() => {
|
||||
function onClickOutside(e: MouseEvent) {
|
||||
if (searchBoxRef.current && !searchBoxRef.current.contains(e.target as Node)) {
|
||||
setSearchOpen(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", onClickOutside)
|
||||
return () => document.removeEventListener("mousedown", onClickOutside)
|
||||
}, [])
|
||||
|
||||
function goToSearchResult(result: GlobalSearchResult) {
|
||||
setSearchOpen(false)
|
||||
setSearchQuery("")
|
||||
setSearchResults([])
|
||||
router.push(SEARCH_TYPE_META[result.type].href(result.id))
|
||||
}
|
||||
|
||||
const searchGrouped = (["Item", "PurchaseOrder", "Invoice", "Employee"] as GlobalSearchResultType[])
|
||||
.map((type) => ({ type, items: searchResults.filter((r) => r.type === type) }))
|
||||
.filter((g) => g.items.length > 0)
|
||||
|
||||
const markAllAsRead = () => {
|
||||
setNotifications((prev) => prev.map((n) => ({ ...n, isRead: true })))
|
||||
setUnreadCount(0)
|
||||
notificationsApi.markAllRead().catch(() => {
|
||||
// Best-effort — a failed mark-all-read is corrected on the next fetch.
|
||||
})
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
try {
|
||||
@@ -264,9 +309,58 @@ export function Header() {
|
||||
)
|
||||
))}
|
||||
|
||||
<div ref={searchBoxRef} className="relative hidden w-56 shrink-0 sm:block lg:w-72">
|
||||
<Search className="pointer-events-none absolute top-1/2 left-3 size-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => {
|
||||
setSearchQuery(e.target.value)
|
||||
setSearchOpen(true)
|
||||
}}
|
||||
onFocus={() => setSearchOpen(true)}
|
||||
placeholder="Search item, PO, invoice, employee…"
|
||||
className="h-9 w-full rounded-full border border-transparent bg-muted pr-3 pl-9 text-xs text-foreground outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||
/>
|
||||
{searchOpen && searchQuery.trim().length >= 2 && (
|
||||
<div className="absolute top-full right-0 z-50 mt-2 max-h-96 w-80 overflow-y-auto rounded-xl bg-popover py-1.5 text-sm shadow-md ring-1 ring-foreground/10">
|
||||
{searchResults.length === 0 ? (
|
||||
<p className="p-4 text-center text-sm text-muted-foreground">No matches found.</p>
|
||||
) : (
|
||||
searchGrouped.map((group) => {
|
||||
const meta = SEARCH_TYPE_META[group.type]
|
||||
const Icon = meta.icon
|
||||
return (
|
||||
<div key={group.type}>
|
||||
<p className="px-4 pt-1 pb-1 text-xs font-medium text-muted-foreground">{meta.label}</p>
|
||||
{group.items.map((item) => (
|
||||
<button
|
||||
key={`${item.type}-${item.id}`}
|
||||
type="button"
|
||||
onClick={() => goToSearchResult(item)}
|
||||
className="flex w-full items-center gap-3 px-4 py-2 text-left hover:bg-primary/10"
|
||||
>
|
||||
<Icon className="size-4 shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-foreground">{item.title}</p>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{item.number}
|
||||
{item.subtitle ? ` · ${item.subtitle}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ThemeToggle />
|
||||
|
||||
<Popover>
|
||||
<Popover onOpenChange={(open) => open && loadNotifications()}>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<button
|
||||
@@ -306,24 +400,24 @@ export function Header() {
|
||||
) : (
|
||||
notifications.map((notification) => (
|
||||
<div
|
||||
key={notification.id}
|
||||
key={notification.notificationId}
|
||||
className={cn(
|
||||
"flex gap-3 border-b border-foreground/10 px-4 py-3 last:border-0",
|
||||
notification.unread && "bg-primary/10"
|
||||
!notification.isRead && "bg-primary/10"
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"mt-1.5 size-2 shrink-0 rounded-full",
|
||||
notification.unread ? "bg-primary" : "bg-transparent"
|
||||
!notification.isRead ? "bg-primary" : "bg-transparent"
|
||||
)}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-foreground">{notification.title}</p>
|
||||
<p className="truncate text-sm text-muted-foreground">
|
||||
{notification.description}
|
||||
{notification.message}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{notification.time}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{relativeTime(notification.createdAt)}</p>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
@@ -349,7 +443,7 @@ export function Header() {
|
||||
{user ? displayName(user) : "Signed in"}
|
||||
</p>
|
||||
{user?.email && <p className="text-sm font-normal text-muted-foreground">{user.email}</p>}
|
||||
{branchLabel && (
|
||||
{branchingEnabled && branchLabel && (
|
||||
<p className="mt-1 flex items-center gap-1.5 text-sm font-normal text-muted-foreground">
|
||||
<Building2 className="size-3.5" />
|
||||
{branchLabel}
|
||||
|
||||
Reference in New Issue
Block a user