new updates
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user