Files
Client_Fe/src/components/hexa/views/roles-permissions-view.tsx
T
Dhananjaya99 a1b870fa8a first commit
2026-09-28 11:48:14 +05:30

348 lines
11 KiB
TypeScript

"use client"
import * as React from "react"
import { toast } from "sonner"
import { PlusIcon, ShieldIcon, Trash2Icon, UsersRoundIcon } from "lucide-react"
import { getStaff } from "@/lib/api/staff"
import {
assignStaffRole,
createGymRole,
deleteGymRole,
getGymRoles,
setRolePermissions,
} from "@/lib/api/gym-roles"
import type { GymRole } from "@/lib/models/gym-role"
import type { StaffMember } from "@/lib/models/staff"
import { NAV_ITEMS } from "@/lib/nav"
import { useFetch } from "@/lib/use-fetch"
import { runMutation } from "@/lib/use-mutation"
import { useNavItems } from "@/hooks/use-nav-items"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
import { Checkbox } from "@/components/ui/checkbox"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { PageSkeleton } from "@/components/hexa/page-skeleton"
// "dashboard" is always visible to every staff member and "settings" is owner-only —
// neither is a meaningful permission to grant/deny, so they're excluded from the checklist.
const NOT_ASSIGNABLE = new Set(["dashboard", "settings"])
const DEMO_ROLES: GymRole[] = [
{
gymRoleId: "demo-front-desk",
name: "Front Desk",
isActive: true,
navKeys: ["attendance", "members", "memberships", "customer-payments"],
},
{
gymRoleId: "demo-trainer",
name: "Trainer",
isActive: true,
navKeys: ["attendance", "members", "workout-plans", "meal-plans", "feed"],
},
]
const DEMO_ROLE_STAFF: StaffMember[] = [
{
staffId: "demo-staff-1",
userId: "demo-user-1",
userName: "Amaya Rodrigo",
email: "amaya@pulsefitness.lk",
phoneNumber: "077 201 4455",
clientId: "demo",
clientName: "Pulse Fitness Colombo",
gymRoleId: "demo-front-desk",
gymRoleName: "Front Desk",
staffPosition: "Receptionist",
department: "Operations",
hireDate: "2025-11-04",
isActive: true,
createdAt: "2025-11-04",
},
{
staffId: "demo-staff-2",
userId: "demo-user-2",
userName: "Ravindu Senanayake",
email: "ravindu@pulsefitness.lk",
phoneNumber: "071 488 2210",
clientId: "demo",
clientName: "Pulse Fitness Colombo",
gymRoleId: "demo-trainer",
gymRoleName: "Trainer",
staffPosition: "Senior Trainer",
department: "Coaching",
hireDate: "2025-08-12",
isActive: true,
createdAt: "2025-08-12",
},
]
interface RolesPermissionsViewProps {
/** Sandbox mode keeps roles, permissions, and assignments in local state. */
demo?: boolean
}
function RolesPermissionsView({ demo = false }: RolesPermissionsViewProps) {
const rolesFetch = useFetch(demo ? async () => DEMO_ROLES : getGymRoles)
const staffFetch = useFetch(demo ? async () => DEMO_ROLE_STAFF : getStaff)
const { items: liveNavItems } = useNavItems(!demo)
const navItems = demo ? NAV_ITEMS : liveNavItems
const [newRoleName, setNewRoleName] = React.useState("")
const [creating, setCreating] = React.useState(false)
// Per-row busy tracking: gymRoleId (delete), `${gymRoleId}:${navKey}` (toggle), staffId (assign).
const [busyKeys, setBusyKeys] = React.useState<Set<string>>(new Set())
function withBusy(key: string) {
setBusyKeys((prev) => new Set(prev).add(key))
}
function clearBusy(key: string) {
setBusyKeys((prev) => {
const next = new Set(prev)
next.delete(key)
return next
})
}
const assignablePermissions = navItems.filter((i) => !NOT_ASSIGNABLE.has(i.key))
const roles = rolesFetch.data ?? []
const staff = staffFetch.data ?? []
async function handleCreateRole() {
if (!newRoleName.trim()) {
toast.error("Enter a role name first.")
return
}
setCreating(true)
try {
if (demo) {
const role: GymRole = {
gymRoleId: `demo-role-${roles.length + 1}`,
name: newRoleName.trim(),
isActive: true,
navKeys: [],
}
rolesFetch.mutate((current) => [...(current ?? []), role])
setNewRoleName("")
toast.success("Demo role created")
return
}
await runMutation(() => createGymRole({ name: newRoleName.trim() }))
setNewRoleName("")
toast.success("Role created")
rolesFetch.reload()
} catch {
// runMutation() already toasted the real error.
} finally {
setCreating(false)
}
}
async function handleDeleteRole(role: GymRole) {
if (demo) {
rolesFetch.mutate((current) =>
current?.filter((item) => item.gymRoleId !== role.gymRoleId) ?? null
)
staffFetch.mutate((current) =>
current?.map((member) =>
member.gymRoleId === role.gymRoleId
? { ...member, gymRoleId: null, gymRoleName: null }
: member
) ?? null
)
toast("Demo role deleted")
return
}
withBusy(role.gymRoleId)
try {
await runMutation(() => deleteGymRole(role.gymRoleId))
toast("Role deleted")
rolesFetch.reload()
} catch {
// runMutation() already toasted the real error.
} finally {
clearBusy(role.gymRoleId)
}
}
async function handleTogglePermission(role: GymRole, navKey: string, checked: boolean) {
const nextKeys = checked
? [...role.navKeys, navKey]
: role.navKeys.filter((k) => k !== navKey)
if (demo) {
rolesFetch.mutate((current) =>
current?.map((item) =>
item.gymRoleId === role.gymRoleId ? { ...item, navKeys: nextKeys } : item
) ?? null
)
return
}
const key = `${role.gymRoleId}:${navKey}`
withBusy(key)
try {
await runMutation(() => setRolePermissions(role.gymRoleId, { navKeys: nextKeys }))
rolesFetch.reload()
} catch {
// runMutation() already toasted the real error.
} finally {
clearBusy(key)
}
}
async function handleAssignRole(staffId: string, gymRoleId: string | null) {
if (demo) {
const roleName = roles.find((role) => role.gymRoleId === gymRoleId)?.name ?? null
staffFetch.mutate((current) =>
current?.map((member) =>
member.staffId === staffId
? { ...member, gymRoleId, gymRoleName: roleName }
: member
) ?? null
)
toast.success("Demo staff role updated")
return
}
withBusy(staffId)
try {
await runMutation(() => assignStaffRole(staffId, gymRoleId))
toast.success("Staff role updated")
staffFetch.reload()
} catch {
// runMutation() already toasted the real error.
} finally {
clearBusy(staffId)
}
}
if (rolesFetch.loading || staffFetch.loading) {
return <PageSkeleton variant="stats-table" />
}
return (
<div className="flex flex-col gap-4">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<ShieldIcon className="size-4" />
Roles
</CardTitle>
<CardDescription>
Create custom roles and choose which sections of the dashboard each one can access.
New roles start with zero permissions.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="flex gap-2">
<Input
placeholder="New role name, e.g. Front Desk"
value={newRoleName}
onChange={(e) => setNewRoleName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleCreateRole()}
/>
<Button onClick={handleCreateRole} disabled={creating}>
<PlusIcon />
Add role
</Button>
</div>
{roles.length === 0 && (
<p className="text-sm text-muted-foreground">No roles yet. Add one above to get started.</p>
)}
{roles.map((role) => (
<div key={role.gymRoleId} className="rounded-xl border p-4">
<div className="mb-3 flex items-center justify-between">
<p className="font-medium">{role.name}</p>
<Button
variant="destructive"
size="icon-sm"
aria-label="Delete role"
onClick={() => handleDeleteRole(role)}
disabled={busyKeys.has(role.gymRoleId)}
>
<Trash2Icon />
</Button>
</div>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
{assignablePermissions.map((item) => {
const checked = role.navKeys.includes(item.key)
return (
<label
key={item.key}
className="flex items-center gap-2 rounded-lg border px-2.5 py-1.5 text-sm"
>
<Checkbox
checked={checked}
onCheckedChange={(v) => handleTogglePermission(role, item.key, v === true)}
disabled={busyKeys.has(`${role.gymRoleId}:${item.key}`)}
/>
{item.label}
</label>
)
})}
</div>
</div>
))}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<UsersRoundIcon className="size-4" />
Assign staff to a role
</CardTitle>
<CardDescription>
Staff with no role assigned only see the Dashboard until you assign one.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-2">
{staff.length === 0 && (
<p className="text-sm text-muted-foreground">No staff members yet.</p>
)}
{staff.map((member) => (
<div
key={member.staffId}
className="flex items-center justify-between gap-3 rounded-lg border px-3 py-2"
>
<div className="min-w-0">
<p className="truncate text-sm font-medium">{member.userName ?? "Unknown"}</p>
<p className="truncate text-xs text-muted-foreground">{member.staffPosition ?? "—"}</p>
</div>
<div className="w-48 shrink-0">
<Label className="sr-only">Role</Label>
<Select
items={roles.map((r) => ({ value: r.gymRoleId, label: r.name }))}
value={member.gymRoleId ?? null}
onValueChange={(v) => handleAssignRole(member.staffId, v ?? null)}
>
<SelectTrigger className="w-full" disabled={busyKeys.has(member.staffId)}>
<SelectValue placeholder="No role" />
</SelectTrigger>
<SelectContent>
{roles.map((r) => (
<SelectItem key={r.gymRoleId} value={r.gymRoleId}>
{r.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
))}
</CardContent>
</Card>
</div>
)
}
export { RolesPermissionsView }