new updates

This commit is contained in:
2026-09-17 14:22:45 +05:30
parent eb36e0acfa
commit c2b92f3086
261 changed files with 102090 additions and 545 deletions
@@ -56,6 +56,12 @@ interface AuthContextValue {
* app should be hidden entirely, not merely restricted by role.
*/
branchingEnabled: boolean
/**
* Independent sub-toggle for Warehouses only (Settings → Branch Configuration). False
* hides Warehouse's branch picker/badge even while `branchingEnabled` stays true for
* everything else. Always false when `branchingEnabled` itself is false.
*/
warehouseBranchingEnabled: boolean
/** True until the first `/auth/me` response lands. */
loading: boolean
}
@@ -71,6 +77,7 @@ const AuthContext = createContext<AuthContextValue>({
activeBranchName: null,
setActiveBranch: () => {},
branchingEnabled: true,
warehouseBranchingEnabled: true,
loading: true,
})
@@ -88,7 +95,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
.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 })
if (!cancelled) setMe({ roleCode: null, roleName: null, navCodes: [], isAdmin: false, branchId: null, branchName: null, branchingEnabled: true, warehouseBranchingEnabled: true })
})
return () => {
cancelled = true
@@ -121,6 +128,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
activeBranchName: activeBranch?.name ?? null,
setActiveBranch,
branchingEnabled: me?.branchingEnabled ?? true,
warehouseBranchingEnabled: me?.warehouseBranchingEnabled ?? true,
loading: me === null,
}}
>
@@ -0,0 +1,209 @@
"use client"
// Step 2 of the role-permission flow: after nav items/sub-items are picked in
// RolePermissionTree, this lists exactly those selected items — grouped under their
// parent module, main item first then its sub-items — and lets an admin toggle which
// mutating actions (View/Create/Edit/Delete/Approve) the role gets within each —
// ERPCore's RolePermission.Actions bitmask (Domain/Enums/RolePermissionAction.cs). Every
// action pill starts plain (nothing granted yet) for a freshly-checked section — an
// admin has to deliberately click each action to grant it, rather than everything
// showing pre-granted and requiring clicks to take access away. A granted action turns
// the same blue as a checked pill on the Sidebar permissions step, for one consistent
// "selected" look across both steps.
import { Checkbox } from "@/components/ui/checkbox"
import { cn } from "@/lib/utils"
import { ALL_ROLE_PERMISSION_ACTIONS, NavItem } from "@/types/rbac"
interface SelectedRow {
id: number
kind: "nav" | "subnav"
label: string
}
interface SelectedGroup {
navItemId: number
navLabel: string
/** The nav item's own row, when it's a leaf (no children) and was itself selected. */
own: SelectedRow | null
children: SelectedRow[]
}
interface Props {
tree: NavItem[]
selectedNavItemIds: Set<number>
selectedSubNavItemIds: Set<number>
navItemActions: Record<number, number>
subNavItemActions: Record<number, number>
onActionsChange: (navItemActions: Record<number, number>, subNavItemActions: Record<number, number>) => void
}
// Same "selected" style as the sub-item pills on the Sidebar permissions step
// (RolePermissionTree's child chips) — one color for every granted action, not one
// color per action type.
const ACTIVE_CLASS_NAME =
"border-transparent bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-500/20 dark:text-blue-300 dark:hover:bg-blue-500/30"
const ACTION_FLAGS: { bit: number; label: string }[] = [
{ bit: 1, label: "View" },
{ bit: 2, label: "Create" },
{ bit: 4, label: "Edit" },
{ bit: 8, label: "Delete" },
{ bit: 16, label: "Approve" },
]
function actionsFor(id: number, actions: Record<number, number>): number {
return actions[id] ?? 0
}
/**
* Fills in an explicit 0 for every selected id missing from the map before a save, so
* an id the admin never clicked an action for is actually saved as "no actions" — what
* the plain/unselected pills showed — instead of falling through to the backend's
* RolePermission.Actions default of All for any id the request omits.
*/
export function fillUntouchedActions(ids: Iterable<number>, actions: Record<number, number>): Record<number, number> {
const filled = { ...actions }
for (const id of ids) if (!(id in filled)) filled[id] = 0
return filled
}
function buildSelectedGroups(tree: NavItem[], navIds: Set<number>, subNavIds: Set<number>): SelectedGroup[] {
const groups: SelectedGroup[] = []
for (const item of tree) {
const own: SelectedRow | null =
item.children.length === 0 && navIds.has(item.navItemId) ? { id: item.navItemId, kind: "nav", label: item.label } : null
const children: SelectedRow[] = item.children
.filter((c) => subNavIds.has(c.subNavItemId))
.map((c) => ({ id: c.subNavItemId, kind: "subnav" as const, label: c.label }))
if (own || children.length > 0) groups.push({ navItemId: item.navItemId, navLabel: item.label, own, children })
}
return groups
}
/** Same "select all" convenience as the parent-item checkbox on the Sidebar permissions
* step — sits in front of the section's label, not amongst the action pills, and
* grants/clears every action in one click. */
function SelectAllCheckbox({ actions, onToggleAll, small }: { actions: number; onToggleAll: (checked: boolean) => void; small?: boolean }) {
const allChecked = actions === ALL_ROLE_PERMISSION_ACTIONS
const someChecked = actions !== 0 && !allChecked
return (
<Checkbox
checked={allChecked}
indeterminate={someChecked}
onCheckedChange={(checked) => onToggleAll(!!checked)}
className={cn(
"border-2 border-muted-foreground/50 data-checked:border-blue-600! data-checked:bg-blue-600! data-checked:text-white!",
small ? "size-4.5 [&_svg]:size-3.5" : "size-5 [&_svg]:size-4"
)}
/>
)
}
function ActionRow({ actions, onToggle }: { actions: number; onToggle: (bit: number) => void }) {
return (
<div className="flex flex-wrap items-center gap-2">
{ACTION_FLAGS.map(({ bit, label }) => {
const checked = (actions & bit) === bit
return (
<button
key={bit}
type="button"
aria-pressed={checked}
onClick={() => onToggle(bit)}
className={cn(
"rounded-full border px-3 py-1 text-sm font-medium transition-colors",
checked ? ACTIVE_CLASS_NAME : "border-input bg-white text-muted-foreground hover:border-muted-foreground/40 hover:text-foreground"
)}
>
{label}
</button>
)
})}
</div>
)
}
export function RolePermissionActions({
tree,
selectedNavItemIds,
selectedSubNavItemIds,
navItemActions,
subNavItemActions,
onActionsChange,
}: Props) {
const groups = buildSelectedGroups(tree, selectedNavItemIds, selectedSubNavItemIds)
function toggleNav(id: number, bit: number) {
const current = actionsFor(id, navItemActions)
onActionsChange({ ...navItemActions, [id]: current ^ bit }, subNavItemActions)
}
function toggleSubNav(id: number, bit: number) {
const current = actionsFor(id, subNavItemActions)
onActionsChange(navItemActions, { ...subNavItemActions, [id]: current ^ bit })
}
function toggleAllNav(id: number, checked: boolean) {
onActionsChange({ ...navItemActions, [id]: checked ? ALL_ROLE_PERMISSION_ACTIONS : 0 }, subNavItemActions)
}
function toggleAllSubNav(id: number, checked: boolean) {
onActionsChange(navItemActions, { ...subNavItemActions, [id]: checked ? ALL_ROLE_PERMISSION_ACTIONS : 0 })
}
if (groups.length === 0) {
return (
<div className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">
No sections selected — go back and pick at least one.
</div>
)
}
return (
<div className="overflow-hidden rounded-lg border">
<div className="grid grid-cols-[minmax(9rem,14rem)_1fr] items-center gap-x-4 border-b bg-muted/40 px-4 py-2 text-xs font-semibold tracking-wide text-muted-foreground uppercase">
<span>Section</span>
<span>Actions</span>
</div>
{groups.map((group) => (
<div key={group.navItemId} className="border-b last:border-b-0">
{group.own ? (
<div className="grid grid-cols-[minmax(9rem,14rem)_1fr] items-center gap-x-4 bg-white px-4 py-3">
<label className="flex items-center gap-2.5">
<SelectAllCheckbox
actions={actionsFor(group.own.id, navItemActions)}
onToggleAll={(checked) => toggleAllNav(group.own!.id, checked)}
/>
<span className="text-sm font-semibold">{group.own.label}</span>
</label>
<ActionRow actions={actionsFor(group.own.id, navItemActions)} onToggle={(bit) => toggleNav(group.own!.id, bit)} />
</div>
) : (
group.children.length > 0 && (
<div className="bg-muted/20 px-4 py-1.5 text-sm font-semibold text-foreground">{group.navLabel}</div>
)
)}
{group.children.map((child) => (
<div key={child.id} className="grid grid-cols-[minmax(9rem,14rem)_1fr] items-center gap-x-4 bg-white px-4 py-3 pl-8">
<label className="flex items-center gap-2.5">
<span className="text-muted-foreground/40">└</span>
<SelectAllCheckbox
actions={actionsFor(child.id, subNavItemActions)}
onToggleAll={(checked) => toggleAllSubNav(child.id, checked)}
small
/>
<span className="text-sm text-muted-foreground">{child.label}</span>
</label>
<ActionRow actions={actionsFor(child.id, subNavItemActions)} onToggle={(bit) => toggleSubNav(child.id, bit)} />
</div>
))}
</div>
))}
</div>
)
}