feat: add vendor optional fields and configuration hooks

- Implemented VendorOptionalFields component to handle optional vendor details (mobile, email, address) with validation.
- Created useCustomerConfig hook to load optional customer field settings from Sales configuration.
- Developed useProcurementConfig hook to manage Procurement configuration flags.
- Introduced accessConfigApi for managing access configuration settings.
- Added openingBalancesApi for handling opening balances operations.
- Created procurementConfigApi for managing procurement configuration settings.
- Implemented nav-routes for permission-based routing.
- Developed PDF generation for purchase orders with dynamic vendor and item details.
- Added purchase-order tax helpers for tax calculations and formatting.
- Defined types for opening balances and related operations.
This commit is contained in:
Dhananjaya99
2026-10-18 12:46:31 +05:30
parent c27db06880
commit e7b1f270a5
202 changed files with 99906 additions and 1894 deletions
@@ -0,0 +1,79 @@
"use client"
import { useState } from "react"
import Link from "next/link"
import { usePathname } from "next/navigation"
import { Lock, RefreshCw, ShieldOff } from "lucide-react"
import { requiredCodesFor } from "@/lib/nav-routes"
import { cn } from "@/lib/utils"
import { useAuth } from "@/components/auth/AuthProvider"
import { Button, buttonVariants } from "@/components/ui/button"
/**
* Two doors. First: the page is shown only to people who hold a role. Someone who signs in with a valid login but has no usable role
* (none assigned and no default role configured, or their role was deactivated) — or whose account is switched
* off — gets a plain explanation instead of a menu full of "Forbidden" screens. "Check again" re-reads their role
* from the server, so access an administrator just granted appears without signing out.
*
* Second: a person with a role still only opens the pages that role grants, even by typing the address
* (lib/nav-routes.ts maps each page to its permission). The API enforces the same permissions on every call.
*/
export function AccessGate({ children }: { children: React.ReactNode }) {
const { loading, hasRole, accountBlocked, refresh, can, roleName } = useAuth()
const pathname = usePathname() || "/"
const [checking, setChecking] = useState(false)
if (loading) return <>{children}</>
if (hasRole) {
const required = requiredCodesFor(pathname)
if (required === null || required.some((code) => can(code))) return <>{children}</>
return (
<div className="flex flex-col items-center gap-4 py-16 text-center">
<div className="flex size-14 items-center justify-center rounded-full bg-destructive/10 text-destructive">
<ShieldOff className="size-7" />
</div>
<h1 className="text-2xl font-bold text-foreground">You don&apos;t have access to this page</h1>
<p className="max-w-md text-base text-muted-foreground">
{roleName ? `Your role (${roleName}) doesn't include this area.` : "Your role doesn't include this area."} If you need it,
ask an administrator to add it under Settings → Roles.
</p>
<Link href="/dashboard" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
Back to the dashboard
</Link>
</div>
)
}
async function checkAgain() {
setChecking(true)
try {
await refresh()
} finally {
setChecking(false)
}
}
return (
<div className="flex flex-col items-center gap-4 py-16 text-center">
<div className="flex size-14 items-center justify-center rounded-full bg-warning/10 text-warning">
<Lock className="size-7" />
</div>
<h1 className="text-2xl font-bold text-foreground">
{accountBlocked ? "Your account is switched off" : "Waiting for access"}
</h1>
<p className="max-w-md text-base text-muted-foreground">
{accountBlocked
? "An administrator has deactivated this account. Please contact them if you think this is a mistake."
: "You're signed in, but no role has been assigned to your account yet. Ask an administrator to give you one (Settings → Users), then check again."}
</p>
{!accountBlocked && (
<Button variant="outline" size="lg" onClick={checkAgain} disabled={checking}>
<RefreshCw className={checking ? "size-5 animate-spin" : "size-5"} />
{checking ? "Checking…" : "Check again"}
</Button>
)}
</div>
)
}
@@ -1,13 +1,15 @@
"use client"
// Authoritative session context: fetches GET /auth/me once per mount and exposes
// the current role + permitted sidebar nav codes. Replaces trusting the stale,
// client-only `roleId` cached by lib/auth-session.ts for anything access-related
// (that cache remains display-only, e.g. for the header's user name).
import { createContext, useContext, useEffect, useState } from "react"
// Authoritative session context: fetches GET /auth/me and exposes the current role and what it may do.
// The role lives in the ERP (users.RoleId) and is read on every request, so this is refetched whenever the
// window regains focus (and on demand via `refresh`) — a role or permission change made by an administrator
// shows up without signing out. Replaces trusting the stale, client-only `roleId` cached by
// lib/auth-session.ts for anything access-related (that cache remains display-only, e.g. the header's name).
import { createContext, useCallback, useContext, useEffect, useRef, useState } from "react"
import { authApi } from "@/lib/api/auth"
import { MeResponse } from "@/types/rbac"
import { ApiError, FORBIDDEN_EVENT } from "@/lib/api-client"
import { MeResponse, RolePermissionAction } from "@/types/rbac"
interface ActiveBranch {
id: number | null
@@ -19,6 +21,9 @@ interface ActiveBranch {
* display/filter convenience — never trusted server-side. */
const ACTIVE_BRANCH_KEY = "erpcore.activeBranch"
/** Don't re-ask the server more often than this when the window keeps regaining focus. */
const FOCUS_REFRESH_MIN_MS = 15_000
function readStoredActiveBranch(): ActiveBranch | null {
if (typeof window === "undefined") return null
const raw = window.localStorage.getItem(ACTIVE_BRANCH_KEY)
@@ -31,12 +36,28 @@ function readStoredActiveBranch(): ActiveBranch | null {
}
}
/** The names of the actions a permission can grant (Approve, Delete, …). */
export type PermissionActionName = keyof typeof RolePermissionAction
interface AuthContextValue {
roleId: number | null
roleCode: string | null
roleName: string | null
navCodes: string[]
/** True for the Admin role — the only role that may pick any branch (others are locked to their own). */
/** True when the person has a usable role. False means "waiting for access": no role assigned and no default, or an inactive role. */
hasRole: boolean
/** True for the administrator role — the only role that may pick any branch (others are locked to their own). */
isAdmin: boolean
/**
* Whether the role may reach `navCode` (a module, or a screen within one) and — when `action` is given — holds that
* action on exactly that code. Administrators may do everything. Hide or disable UI with this; the server
* enforces the same rules, so this is a convenience, never the protection.
*/
can: (navCode: string, action?: PermissionActionName) => boolean
/** Re-reads the role and permissions from the server (after changing your own role, say). */
refresh: () => Promise<void>
/** True when the account itself is switched off (the server answered 403 to /auth/me). */
accountBlocked: boolean
/** The caller's own branch, via their linked Employee record — null if unlinked. */
branchId: number | null
branchName: string | null
@@ -67,10 +88,15 @@ interface AuthContextValue {
}
const AuthContext = createContext<AuthContextValue>({
roleId: null,
roleCode: null,
roleName: null,
navCodes: [],
hasRole: false,
isAdmin: false,
can: () => false,
refresh: async () => {},
accountBlocked: false,
branchId: null,
branchName: null,
activeBranchId: null,
@@ -81,27 +107,50 @@ const AuthContext = createContext<AuthContextValue>({
loading: true,
})
const NO_ACCESS: MeResponse = {
roleCode: null, roleName: null, navCodes: [], isAdmin: false, branchId: null, branchName: null,
branchingEnabled: true, warehouseBranchingEnabled: true, roleId: null, hasRole: false, permissions: {},
}
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [me, setMe] = useState<MeResponse | null>(null)
const [accountBlocked, setAccountBlocked] = useState(false)
const [activeBranch, setActiveBranchState] = useState<ActiveBranch | null>(null)
const lastFetch = useRef(0)
useEffect(() => {
let cancelled = false
authApi
.me()
.then((res) => {
if (!cancelled) setMe(res)
})
.catch(() => {
// Unauthenticated/unreachable — fall back to "no permissions" rather than
// crash the shell; proxy.ts already redirects unauthenticated users to /login.
if (!cancelled) setMe({ roleCode: null, roleName: null, navCodes: [], isAdmin: false, branchId: null, branchName: null, branchingEnabled: true, warehouseBranchingEnabled: true })
})
return () => {
cancelled = true
const load = useCallback(async () => {
lastFetch.current = Date.now()
try {
const res = await authApi.me()
setMe(res)
setAccountBlocked(false)
} catch (err) {
// 403 = the ERP account is switched off; anything else (unauthenticated/unreachable) falls back to
// "no permissions" rather than crashing the shell — proxy.ts already redirects unauthenticated users to /login.
setAccountBlocked(err instanceof ApiError && err.status === 403)
setMe((prev) => prev ?? NO_ACCESS)
}
}, [])
useEffect(() => {
void load()
}, [load])
// The role is read from the ERP on every request, so keep the UI in step with it: refetch when the person comes
// back to the tab (rate-limited), which picks up a role or permission change an administrator just made.
useEffect(() => {
function onFocus() {
if (Date.now() - lastFetch.current >= FOCUS_REFRESH_MIN_MS) void load()
}
// A 403 from any call means what this tab believes the role can do may be stale: re-read it (same rate limit).
window.addEventListener("focus", onFocus)
window.addEventListener(FORBIDDEN_EVENT, onFocus)
return () => {
window.removeEventListener("focus", onFocus)
window.removeEventListener(FORBIDDEN_EVENT, onFocus)
}
}, [load])
useEffect(() => {
if (me === null) return
// Non-admins are always pinned to their own branch — never their own past picks.
@@ -115,13 +164,33 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
if (typeof window !== "undefined") window.localStorage.setItem(ACTIVE_BRANCH_KEY, JSON.stringify(next))
}
const can = useCallback(
(navCode: string, action?: PermissionActionName): boolean => {
if (!me) return false
if (me.isAdmin) return true
const permissions = me.permissions ?? {}
if (action) {
const granted = permissions[navCode]
return granted !== undefined && (granted & RolePermissionAction[action]) !== 0
}
// Reaching a module: the code itself, or any screen inside it ("procurement" matches "procurement.vendors").
return navCode in permissions || Object.keys(permissions).some((c) => c.startsWith(navCode + "."))
},
[me],
)
return (
<AuthContext.Provider
value={{
roleId: me?.roleId ?? null,
roleCode: me?.roleCode ?? null,
roleName: me?.roleName ?? null,
navCodes: me?.navCodes ?? [],
hasRole: me?.hasRole ?? false,
isAdmin: me?.isAdmin ?? false,
can,
refresh: load,
accountBlocked,
branchId: me?.branchId ?? null,
branchName: me?.branchName ?? null,
activeBranchId: activeBranch?.id ?? null,