feat: implement theme toggle and enhance UI components

This commit is contained in:
2026-07-23 12:53:26 +05:30
parent 5cf9588728
commit 7366ca93c0
11 changed files with 173 additions and 120 deletions
@@ -39,6 +39,9 @@ const navItems: {
title: string
code: string
href: string
/** Where clicking the row actually navigates, if different from `href`. `href` itself
* stays the section prefix used to decide whether this row is "active". */
landingHref?: string
icon: LucideIcon
chevron?: boolean
children?: { title: string; code: string; href: string; icon: LucideIcon }[]
@@ -57,18 +60,21 @@ const navItems: {
{ title: "UOM", code: "products.uom", href: "/dashboard/products/uoms", icon: Ruler },
],
},
{ title: "Vendors", code: "vendors", href: "/dashboard/vendors", icon: Truck, chevron: true },
{
title: "Procurement",
code: "procurement",
href: "/dashboard/procurement",
// Clicking "Procurement" itself lands on Purchase Orders — the hub page underneath
// has nothing on it (its card grid was removed once the sidebar grew these sub-items).
landingHref: "/dashboard/procurement/purchase-orders",
icon: ClipboardList,
chevron: true,
children: [
{ title: "Requisitions", code: "procurement.requisitions", href: "/dashboard/procurement/requisitions", icon: ClipboardList },
{ title: "RFQs", code: "procurement.rfqs", href: "/dashboard/procurement/rfqs", icon: FileText },
{ title: "Purchase Orders", code: "procurement.purchase-orders", href: "/dashboard/procurement/purchase-orders", icon: ShoppingCart },
{ title: "Purchase Returns", code: "procurement.purchase-returns", href: "/dashboard/procurement/purchase-returns", icon: PackageX },
{ title: "Vendors", code: "procurement.vendors", href: "/dashboard/vendors", icon: Truck },
{ title: "Requisitions", code: "procurement.requisitions", href: "/dashboard/procurement/requisitions", icon: ClipboardList },
{ title: "RFQs", code: "procurement.rfqs", href: "/dashboard/procurement/rfqs", icon: FileText },
],
},
{ title: "Receiving", code: "receiving", href: "/dashboard/receiving/grn", icon: PackageCheck, chevron: true },
@@ -126,7 +132,7 @@ function SidebarContent({
return (
<nav
className={cn(
"flex h-full flex-col rounded-3xl bg-white p-3 shadow-sm ring-1 ring-black/5 transition-[width] duration-300 ease-in-out",
"flex h-full flex-col rounded-3xl bg-card p-3 shadow-sm ring-1 ring-foreground/10 transition-[width] duration-300 ease-in-out",
!isMobile && (collapsed ? "w-20" : "w-64")
)}
>
@@ -138,13 +144,13 @@ function SidebarContent({
)}
>
<Link href="/dashboard" className="flex items-center gap-2.5" onClick={onClose}>
<div className="flex size-8 shrink-0 items-center justify-center rounded-xl bg-indigo-50">
<svg viewBox="0 0 48 32" className="h-3.5 w-5 fill-indigo-600">
<div className="flex size-8 shrink-0 items-center justify-center rounded-xl bg-primary/10">
<svg viewBox="0 0 48 32" className="h-3.5 w-5 fill-primary">
<path d="M24 16c-3-8-11-12-16-8s-3 14 6 14c5 0 8.5-2.5 10-6 1.5 3.5 5 6 10 6 9 0 11-10 6-14s-13 0-16 8Z" />
</svg>
</div>
{!iconOnly && (
<span className="text-lg font-bold tracking-tight text-slate-900">Hexa ERP</span>
<span className="text-lg font-bold tracking-tight text-foreground">Hexa ERP</span>
)}
</Link>
@@ -152,7 +158,7 @@ function SidebarContent({
type="button"
onClick={isMobile ? onClose : onCollapse}
aria-label={isMobile ? "Close menu" : collapsed ? "Expand sidebar" : "Minimize sidebar"}
className="flex size-8 shrink-0 items-center justify-center rounded-xl text-slate-400 hover:bg-slate-50 hover:text-slate-600"
className="flex size-8 shrink-0 items-center justify-center rounded-xl text-muted-foreground hover:bg-muted hover:text-foreground"
>
{isMobile ? <X className="size-4" /> : <Menu className="size-4" />}
</button>
@@ -172,28 +178,28 @@ function SidebarContent({
<div
className={cn(
"flex items-center rounded-2xl transition-colors",
isActive ? "bg-indigo-50" : "hover:bg-slate-50"
isActive ? "bg-primary/10" : "hover:bg-muted"
)}
>
<Link
href={item.href}
href={item.landingHref ?? item.href}
title={iconOnly ? item.title : undefined}
onClick={onClose}
className={cn(
"flex flex-1 items-center gap-3 rounded-2xl px-4 py-3 text-base font-semibold",
iconOnly && "justify-center px-0",
isActive ? "text-indigo-600" : "text-slate-700"
isActive ? "text-primary" : "text-foreground"
)}
>
<item.icon
className={cn("size-5 shrink-0", isActive ? "text-indigo-600" : "text-slate-400")}
className={cn("size-5 shrink-0", isActive ? "text-primary" : "text-muted-foreground")}
/>
{!iconOnly && (
<>
<span className="flex-1">{item.title}</span>
{item.chevron && !hasChildren && (
<ChevronRight
className={cn("size-4 shrink-0", isActive ? "text-indigo-400" : "text-slate-300")}
className={cn("size-4 shrink-0", isActive ? "text-primary/70" : "text-muted-foreground")}
/>
)}
</>
@@ -207,8 +213,8 @@ function SidebarContent({
aria-label={isOpen ? `Collapse ${item.title}` : `Expand ${item.title}`}
aria-expanded={isOpen}
className={cn(
"mr-2 flex size-7 shrink-0 items-center justify-center rounded-lg transition-colors hover:bg-white/60",
isActive ? "text-indigo-500" : "text-slate-400"
"mr-2 flex size-7 shrink-0 items-center justify-center rounded-lg transition-colors hover:bg-foreground/10",
isActive ? "text-primary" : "text-muted-foreground"
)}
>
<ChevronRight
@@ -245,12 +251,12 @@ function SidebarContent({
className={cn(
"flex items-center gap-2.5 rounded-xl px-3 py-2 text-sm font-medium transition-colors",
childActive
? "bg-indigo-50 text-indigo-600"
: "text-slate-500 hover:bg-slate-50 hover:text-slate-700"
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:bg-muted hover:text-foreground"
)}
>
<child.icon
className={cn("size-4 shrink-0", childActive ? "text-indigo-600" : "text-slate-400")}
className={cn("size-4 shrink-0", childActive ? "text-primary" : "text-muted-foreground")}
/>
{child.title}
</Link>
@@ -268,8 +274,8 @@ function SidebarContent({
</ul>
<div className="flex items-center justify-center pt-6">
<div className="flex size-12 items-center justify-center rounded-2xl bg-slate-50 ring-1 ring-black/5">
<svg viewBox="0 0 48 32" className="h-4 w-6 fill-slate-400">
<div className="flex size-12 items-center justify-center rounded-2xl bg-muted ring-1 ring-foreground/10">
<svg viewBox="0 0 48 32" className="h-4 w-6 fill-muted-foreground">
<path d="M24 16c-3-8-11-12-16-8s-3 14 6 14c5 0 8.5-2.5 10-6 1.5 3.5 5 6 10 6 9 0 11-10 6-14s-13 0-16 8Z" />
</svg>
</div>
@@ -287,13 +293,18 @@ export function AppSidebar() {
// While /auth/me hasn't resolved yet, show nothing rather than briefly
// flashing the full menu to a restricted role. Once resolved, a nav item
// is visible if its own code is granted, or (for parents) if any child is.
//
// "procurement" is exempted from that check (frontend-only): no role is currently
// seeded with NAV:procurement or its children server-side, which would hide the whole
// section for everyone. Remove this bypass once roles are granted the permission
// properly (Settings → Roles → Sidebar permissions) or a backend seed grants it.
const visibleItems = loading
? []
: navItems
.filter((item) => navCodes.includes(item.code) || item.children?.some((c) => navCodes.includes(c.code)))
.filter((item) => item.code === "procurement" || navCodes.includes(item.code) || item.children?.some((c) => navCodes.includes(c.code)))
.map((item) => ({
...item,
children: item.children?.filter((c) => navCodes.includes(c.code)),
children: item.code === "procurement" ? item.children : item.children?.filter((c) => navCodes.includes(c.code)),
}))
// Close mobile menu on route change
@@ -326,7 +337,7 @@ export function AppSidebar() {
type="button"
onClick={() => setMobileOpen(true)}
aria-label="Open menu"
className="fixed top-5 left-5 z-40 flex size-10 items-center justify-center rounded-2xl bg-white shadow-sm ring-1 ring-black/5 text-slate-600 hover:bg-slate-50 lg:hidden"
className="fixed top-5 left-5 z-40 flex size-10 items-center justify-center rounded-2xl bg-card shadow-sm ring-1 ring-foreground/10 text-muted-foreground hover:bg-muted lg:hidden"
>
<Menu className="size-5" />
</button>
@@ -9,6 +9,7 @@ import { authApi } from "@/lib/api/auth"
import { clearStoredUser, displayName, getStoredUser } from "@/lib/auth-session"
import { AuthUser } from "@/types/auth"
import { cn } from "@/lib/utils"
import { ThemeToggle } from "@/components/theme-toggle"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { Badge } from "@/components/ui/badge"
import {
@@ -163,48 +164,50 @@ export function Header() {
}
return (
<header className="mx-6 mt-3 mb-6 flex items-center justify-between gap-2 rounded-3xl bg-white py-4 pr-4 pl-14 shadow-sm ring-1 ring-black/5 lg:mx-8 lg:mt-4 lg:gap-4 lg:p-4">
<header className="mx-6 mt-3 mb-6 flex items-center justify-between gap-2 rounded-3xl bg-card py-4 pr-4 pl-14 shadow-sm ring-1 ring-foreground/10 lg:mx-8 lg:mt-4 lg:gap-4 lg:p-4">
<div className="flex items-center gap-3">
{showBackButton && (
<button
type="button"
onClick={() => router.back()}
aria-label="Go back"
className="flex size-9 shrink-0 items-center justify-center rounded-full text-slate-500 hover:bg-slate-50 hover:text-slate-700"
className="flex size-9 shrink-0 items-center justify-center rounded-full text-muted-foreground hover:bg-muted hover:text-foreground"
>
<ArrowLeft className="size-5" />
</button>
)}
<h1 className="text-base font-bold tracking-tight text-slate-900 sm:text-xl">{title}</h1>
<h1 className="text-base font-bold tracking-tight text-foreground sm:text-xl">{title}</h1>
</div>
<div className="flex items-center gap-2 sm:gap-3">
<ThemeToggle />
<Popover>
<PopoverTrigger
render={
<button
type="button"
aria-label="Notifications"
className="relative flex size-10 shrink-0 items-center justify-center rounded-full text-slate-500 hover:bg-slate-50 hover:text-slate-700"
className="relative flex size-10 shrink-0 items-center justify-center rounded-full text-muted-foreground hover:bg-muted hover:text-foreground"
/>
}
>
<Bell className="size-5" />
{unreadCount > 0 && (
<Badge className="absolute top-1.5 right-1.5 size-2 rounded-full bg-indigo-600 p-0" />
<Badge className="absolute top-1.5 right-1.5 size-2 rounded-full bg-primary p-0" />
)}
</PopoverTrigger>
<PopoverContent
align="end"
className="w-[calc(100vw-2rem)] max-w-sm sm:w-96"
>
<div className="flex items-center justify-between border-b border-black/5 px-4 py-3">
<p className="text-sm font-semibold text-slate-900">Notifications</p>
<div className="flex items-center justify-between border-b border-foreground/10 px-4 py-3">
<p className="text-sm font-semibold text-foreground">Notifications</p>
{unreadCount > 0 && (
<button
type="button"
onClick={markAllAsRead}
className="text-xs font-medium text-indigo-600 hover:underline"
className="text-xs font-medium text-primary hover:underline"
>
Mark all as read
</button>
@@ -221,22 +224,22 @@ export function Header() {
<div
key={notification.id}
className={cn(
"flex gap-3 border-b border-black/5 px-4 py-3 last:border-0",
notification.unread && "bg-indigo-50/50"
"flex gap-3 border-b border-foreground/10 px-4 py-3 last:border-0",
notification.unread && "bg-primary/10"
)}
>
<span
className={cn(
"mt-1.5 size-2 shrink-0 rounded-full",
notification.unread ? "bg-indigo-600" : "bg-transparent"
notification.unread ? "bg-primary" : "bg-transparent"
)}
/>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-slate-900">{notification.title}</p>
<p className="text-sm font-medium text-foreground">{notification.title}</p>
<p className="truncate text-sm text-muted-foreground">
{notification.description}
</p>
<p className="mt-1 text-xs text-slate-400">{notification.time}</p>
<p className="mt-1 text-xs text-muted-foreground">{notification.time}</p>
</div>
</div>
))
@@ -246,19 +249,19 @@ export function Header() {
</Popover>
<DropdownMenu>
<DropdownMenuTrigger className="flex items-center gap-2 rounded-full py-1 pr-2 pl-1 outline-none hover:bg-slate-50">
<DropdownMenuTrigger className="flex items-center gap-2 rounded-full py-1 pr-2 pl-1 outline-none hover:bg-muted">
<Avatar>
<AvatarFallback className="bg-indigo-50 font-semibold text-indigo-600">
<AvatarFallback className="bg-primary/10 font-semibold text-primary">
{initials(displayName(user))}
</AvatarFallback>
</Avatar>
<span className="hidden text-sm font-semibold text-slate-700 sm:block">
<span className="hidden text-sm font-semibold text-foreground sm:block">
{displayName(user)}
</span>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-80 p-2">
<div className="px-2 py-2.5">
<p className="text-base font-semibold text-slate-900">{displayName(user)}</p>
<p className="text-base font-semibold text-foreground">{displayName(user)}</p>
{user?.email && <p className="text-sm font-normal text-muted-foreground">{user.email}</p>}
</div>
<DropdownMenuSeparator />
@@ -0,0 +1,38 @@
"use client"
import { useEffect, useState } from "react"
import { Moon, Sun } from "lucide-react"
import { useTheme } from "next-themes"
import { cn } from "@/lib/utils"
/**
* Renders an empty slot until mounted: `resolvedTheme` is unknown on the server (and on
* the client's first paint, before next-themes reads localStorage), so rendering an icon
* before that would either be wrong or cause a hydration mismatch.
*/
export function ThemeToggle({ className }: { className?: string }) {
const { resolvedTheme, setTheme } = useTheme()
const [mounted, setMounted] = useState(false)
useEffect(() => setMounted(true), [])
if (!mounted) {
return <div className={cn("size-10 shrink-0", className)} aria-hidden="true" />
}
const isDark = resolvedTheme === "dark"
return (
<button
type="button"
onClick={() => setTheme(isDark ? "light" : "dark")}
aria-label={isDark ? "Switch to light mode" : "Switch to dark mode"}
className={cn(
"flex size-10 shrink-0 items-center justify-center rounded-full text-muted-foreground hover:bg-muted hover:text-foreground",
className
)}
>
{isDark ? <Sun className="size-5" /> : <Moon className="size-5" />}
</button>
)
}