new updates

This commit is contained in:
2026-09-17 14:22:45 +05:30
committed by ImanThiyanga
parent e1f19fe413
commit 8c3d62223c
261 changed files with 102091 additions and 549 deletions
@@ -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 },
],
},
{
+143 -49
View File
@@ -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}