product and sales fixers

This commit is contained in:
2026-09-30 17:06:28 +05:30
parent 9fd290e256
commit cea433e320
98 changed files with 85367 additions and 1516 deletions
@@ -43,14 +43,12 @@ import {
PieChart,
Receipt,
ReceiptText,
Ruler,
Scale,
ScrollText,
Settings,
ShieldCheck,
ShoppingCart,
SlidersHorizontal,
Tag,
Truck,
Undo2,
Users,
@@ -73,11 +71,7 @@ const navItems: {
/** 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
/** This item's own href has no page of its own (no landingHref either) — clicking the
* row should only expand/collapse its children, never navigate. */
expandOnly?: boolean
icon: LucideIcon
chevron?: boolean
children?: {
title: string
code: string
@@ -94,12 +88,16 @@ const navItems: {
code: "products",
href: "/dashboard/products",
icon: Package,
chevron: true,
children: [
{ title: "Item", code: "products.item", href: "/dashboard/products", icon: Boxes },
{ title: "Category", code: "products.category", href: "/dashboard/products/categories", icon: ListTree },
{ title: "Brand", code: "products.brand", href: "/dashboard/products/brands", icon: Tag },
{ title: "UOM", code: "products.uom", href: "/dashboard/products/uoms", icon: Ruler },
// Category, Brand and UOM share one tabbed page — any of the three permissions shows it.
{
title: "Category/Brand/UOM",
code: "products.category",
href: "/dashboard/products/categories",
icon: ListTree,
altCodes: ["products.brand", "products.uom"],
},
{ title: "Tax Rates", code: "products.tax-rates", href: "/dashboard/products/tax-rates", icon: Percent },
],
},
@@ -111,7 +109,6 @@ const navItems: {
// 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: "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 },
@@ -124,9 +121,11 @@ const navItems: {
href: "/dashboard/sales",
landingHref: "/dashboard/sales/invoices",
icon: ReceiptText,
chevron: true,
children: [
{ title: "Sales", code: "sales.invoices", href: "/dashboard/sales/invoices", icon: FileText },
// Shortcut straight to the New Sale form — reuses the sales.invoices permission code (same
// access as the Sales list), so it needs no seeded SubNavItem of its own.
{ title: "Create Sale", code: "sales.invoices", href: "/dashboard/sales/invoices/new", icon: ShoppingCart },
// Reuses the sales.invoices permission code — customers aren't yet their own
// seeded NavItem/Permission on the backend, so this rides along with Sales access.
{ title: "Customers", code: "sales.invoices", href: "/dashboard/customers", icon: Users },
@@ -137,7 +136,7 @@ const navItems: {
],
},
{ title: "Sales Returns", code: "sales-returns", href: "/dashboard/sales/sales-returns", icon: Undo2 },
{ title: "GRN", code: "receiving", href: "/dashboard/receiving/grn", icon: PackageCheck, chevron: true },
{ title: "GRN", code: "receiving", href: "/dashboard/receiving/grn", icon: PackageCheck },
{
title: "Stock",
code: "stock",
@@ -146,7 +145,6 @@ const navItems: {
// it (its card grid was removed once the sidebar grew these sub-items), same as Procurement.
landingHref: "/dashboard/stock/ledger",
icon: Warehouse,
chevron: true,
children: [
{ title: "Stock Ledger", code: "stock.ledger", href: "/dashboard/stock/ledger", icon: ScrollText },
{ title: "Stock Enquiry", code: "stock.enquiry", href: "/dashboard/stock/enquiry", icon: PackageSearch },
@@ -158,14 +156,13 @@ const navItems: {
{ title: "Wastage", code: "stock.wastage", href: "/dashboard/stock/wastage", icon: AlertOctagon },
],
},
{ title: "Warehouses", code: "warehouses", href: "/dashboard/warehouse", icon: Building2, chevron: true },
{ title: "Warehouses", code: "warehouses", href: "/dashboard/warehouse", icon: Building2 },
{
title: "Production",
code: "production",
href: "/dashboard/production",
landingHref: "/dashboard/production/runs",
icon: Factory,
chevron: true,
children: [
{ title: "Templates", code: "production.templates", href: "/dashboard/production/templates", icon: LayoutTemplate },
{ title: "Runs", code: "production.runs", href: "/dashboard/production/runs", icon: PlayCircle },
@@ -178,7 +175,6 @@ const navItems: {
href: "/dashboard/hrm",
landingHref: "/dashboard/hrm/employees",
icon: IdCard,
chevron: true,
children: [
{ title: "Employees", code: "hrm.employees", href: "/dashboard/hrm/employees", icon: Users },
{ title: "Attendance", code: "hrm.attendance", href: "/dashboard/hrm/attendance", icon: CalendarCheck },
@@ -193,7 +189,6 @@ const navItems: {
code: "ledgers",
href: "/dashboard/ledgers",
icon: Landmark,
chevron: true,
children: [
{ title: "Trial Balance", code: "ledgers.trial-balance", href: "/dashboard/ledgers/trial-balance", icon: Scale },
{ title: "Balance Sheet", code: "ledgers.balance-sheet", href: "/dashboard/ledgers/balance-sheet", icon: Landmark },
@@ -209,7 +204,6 @@ const navItems: {
code: "accounts",
href: "/dashboard/accounts",
icon: CreditCard,
chevron: true,
children: [
{ title: "Cash / Bank Accounts", code: "accounts.bank-accounts", href: "/dashboard/accounts/bank-accounts", icon: Wallet },
{ title: "Cheque Books", code: "accounts.cheque-books", href: "/dashboard/accounts/cheque-books", icon: BookText },
@@ -220,9 +214,7 @@ const navItems: {
title: "Settings",
code: "settings",
href: "/dashboard/settings",
expandOnly: true,
icon: Settings,
chevron: true,
children: [
{ title: "Branches", code: "settings.branches", href: "/dashboard/settings/branches", icon: MapPin },
{ title: "Roles", code: "settings.roles", href: "/dashboard/settings/roles", icon: ShieldCheck },
@@ -329,49 +321,46 @@ function SidebarContent({
isActive ? "bg-primary/10" : "hover:bg-muted"
)}
>
<Link
href={item.landingHref ?? item.href}
title={iconOnly ? item.title : undefined}
onClick={() => {
onClose?.()
if (hasChildren) setExpanded((prev) => (prev[item.code] ? prev : { ...prev, [item.code]: true }))
}}
className={cn(
"flex flex-1 items-center gap-3 rounded-2xl px-4 py-1.5 text-base font-normal",
iconOnly && "justify-center px-0",
isActive ? "text-primary" : "text-foreground"
)}
>
<item.icon
className={cn("size-3.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-primary/70" : "text-muted-foreground")}
/>
)}
</>
)}
</Link>
{hasChildren && (
{hasChildren ? (
// Parents with sub-items only expand/collapse on click — navigation
// happens through the sub-items themselves.
<button
type="button"
onClick={() => toggleExpand(item.code)}
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-foreground/10",
isActive ? "text-primary" : "text-muted-foreground"
"flex flex-1 items-center gap-3 rounded-2xl px-4 py-1.5 text-left text-base font-normal",
isActive ? "text-primary" : "text-foreground"
)}
>
<item.icon
className={cn("size-3.5 shrink-0", isActive ? "text-primary" : "text-muted-foreground")}
/>
<span className="flex-1">{item.title}</span>
<ChevronRight
className={cn("size-4 transition-transform duration-300 ease-in-out", isOpen && "rotate-90")}
className={cn(
"size-4 shrink-0 transition-transform duration-300 ease-in-out",
isActive ? "text-primary" : "text-muted-foreground",
isOpen && "rotate-90"
)}
/>
</button>
) : (
<Link
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-1.5 text-base font-normal",
iconOnly && "justify-center px-0",
isActive ? "text-primary" : "text-foreground"
)}
>
<item.icon
className={cn("size-3.5 shrink-0", isActive ? "text-primary" : "text-muted-foreground")}
/>
{!iconOnly && <span className="flex-1">{item.title}</span>}
</Link>
)}
</div>
@@ -132,10 +132,10 @@ function titleFromPath(pathname: string) {
if (pathname === "/dashboard/products") return "Items"
if (pathname === "/dashboard/products/new") return "New Item"
if (pathname === "/dashboard/products/categories") return "Categories"
if (pathname === "/dashboard/products/categories") return "Category/Brand/UOM"
if (pathname.startsWith("/dashboard/products/categories/")) return "Subcategories"
if (pathname === "/dashboard/products/brands") return "Brand/UOM"
if (pathname === "/dashboard/products/item-types") return "Item Types"
if (pathname === "/dashboard/products/item-types") return "Item Variants"
if (pathname === "/dashboard/products/warranty-periods") return "Warranty Periods"
if (pathname === "/dashboard/products/settings") return "Product Configuration"
if (pathname === "/dashboard/settings/notification-config") return "Notification Config"
if (pathname === "/dashboard/settings/company-profile") return "Company Profile"
@@ -0,0 +1,102 @@
"use client"
import { useEffect, useRef, useState } from "react"
import { usePathname, useSearchParams } from "next/navigation"
/**
* Thin top-of-page progress bar for client-side navigations.
*
* The App Router has no "navigation started" event, so the start is detected from clicks on
* internal links (capture phase, before next/link handles them) and back/forward. The route
* committing — pathname or query changing — finishes it. Programmatic router.push() isn't
* seen at start; app/dashboard/loading.tsx covers those with an in-content spinner.
*/
export function RouteProgress() {
const pathname = usePathname()
const searchParams = useSearchParams()
const [progress, setProgress] = useState(0)
const [visible, setVisible] = useState(false)
// Set by the mount effect; lets the route-change effect end the bar.
const finishRef = useRef<() => void>(() => {})
useEffect(() => {
let active = false
let trickle: ReturnType<typeof setInterval> | null = null
let safety: ReturnType<typeof setTimeout> | null = null
let hide: ReturnType<typeof setTimeout> | null = null
function clearTimers() {
if (trickle) clearInterval(trickle)
if (safety) clearTimeout(safety)
if (hide) clearTimeout(hide)
trickle = safety = hide = null
}
function start() {
if (active) return
active = true
clearTimers()
setVisible(true)
setProgress(8)
// Ease toward 90% — never reaches the end until the route actually commits.
trickle = setInterval(() => setProgress((p) => (p < 90 ? p + (90 - p) * 0.1 : p)), 200)
// Never leave a bar hanging if a navigation is cancelled or fails silently.
safety = setTimeout(finish, 15000)
}
function finish() {
if (!active) return
active = false
clearTimers()
setProgress(100)
hide = setTimeout(() => {
setVisible(false)
setProgress(0)
}, 250)
}
finishRef.current = finish
function onClick(e: MouseEvent) {
if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return
const anchor = (e.target as Element | null)?.closest?.("a")
if (!anchor || !anchor.href) return
if (anchor.target && anchor.target !== "_self") return
if (anchor.hasAttribute("download")) return
const next = new URL(anchor.href, window.location.href)
if (next.origin !== window.location.origin) return
// API downloads (e.g. document links) aren't page navigations.
if (next.pathname.startsWith("/api/")) return
// Same page (or hash-only jump) — nothing will commit, so don't start.
if (next.pathname === window.location.pathname && next.search === window.location.search) return
start()
}
document.addEventListener("click", onClick, true)
window.addEventListener("popstate", start)
return () => {
document.removeEventListener("click", onClick, true)
window.removeEventListener("popstate", start)
clearTimers()
}
}, [])
// Route committed ⇒ done. Keyed on the full URL so query-only navigations count too.
const url = `${pathname}?${searchParams.toString()}`
useEffect(() => {
finishRef.current()
}, [url])
return (
<div
aria-hidden
className="pointer-events-none fixed inset-x-0 top-0 z-100 h-0.5 print:hidden"
style={{ opacity: visible ? 1 : 0, transition: "opacity 250ms ease" }}
>
<div
className="h-full bg-primary shadow-[0_0_8px_var(--color-primary)]"
style={{ width: `${progress}%`, transition: "width 200ms ease-out" }}
/>
</div>
)
}