first commit
This commit is contained in:
@@ -0,0 +1,347 @@
|
||||
"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 }
|
||||
Reference in New Issue
Block a user