Update permission feature with edit and create role functionality
This commit is contained in:
@@ -59,8 +59,15 @@ function Kpi({ title, value, footer }: { title: string; value: string; footer?:
|
|||||||
|
|
||||||
function LoadingGrid() {
|
function LoadingGrid() {
|
||||||
return (
|
return (
|
||||||
|
<div className="flex flex-col gap-3" aria-busy="true">
|
||||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-3 xl:grid-cols-6">
|
<div className="grid grid-cols-2 gap-3 md:grid-cols-3 xl:grid-cols-6">
|
||||||
{Array.from({ length: 8 }).map((_, i) => <Skeleton key={i} className="h-[76px] rounded-xl" />)}
|
{Array.from({ length: 6 }).map((_, i) => <Skeleton key={i} className="h-[76px] rounded-xl" />)}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
|
<Skeleton key={i} className={`h-[262px] rounded-xl ${i % 3 === 0 ? 'md:col-span-2' : ''}`} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -161,11 +168,11 @@ const sharePct = (v: number, total: number) => (total > 0 ? `${((v / total) * 10
|
|||||||
export function SalesSection({ range }: { range: DateRange }) {
|
export function SalesSection({ range }: { range: DateRange }) {
|
||||||
const { data: sales, loading } = useLoad<DashboardSalesSummary>(
|
const { data: sales, loading } = useLoad<DashboardSalesSummary>(
|
||||||
() => dashboardReports.sales(range), [range.from, range.to], 'Failed to load sales analytics')
|
() => dashboardReports.sales(range), [range.from, range.to], 'Failed to load sales analytics')
|
||||||
const { data: products } = useLoad<ProductSalesRow[]>(
|
const { data: products, loading: productsLoading } = useLoad<ProductSalesRow[]>(
|
||||||
() => productReports.sales({ ...range, pageSize: 10 }).then(r => getPaginatedItems(r)),
|
() => productReports.sales({ ...range, pageSize: 10 }).then(r => getPaginatedItems(r)),
|
||||||
[range.from, range.to], 'Failed to load product sales')
|
[range.from, range.to], 'Failed to load product sales')
|
||||||
|
|
||||||
if (loading || !sales) return <LoadingGrid />
|
if (loading || productsLoading || !sales) return <LoadingGrid />
|
||||||
|
|
||||||
const { current: c, previous: p } = sales.kpis
|
const { current: c, previous: p } = sales.kpis
|
||||||
const kpi = (title: string, key: keyof SalesKpis, money = true) => (
|
const kpi = (title: string, key: keyof SalesKpis, money = true) => (
|
||||||
@@ -252,11 +259,11 @@ export function InventorySection({ range }: { range: DateRange }) {
|
|||||||
const { data: lowStock } = useLoad(
|
const { data: lowStock } = useLoad(
|
||||||
() => productReports.stock({ lowStockOnly: true, pageSize: 50 }).then(r => getPaginatedItems(r)),
|
() => productReports.stock({ lowStockOnly: true, pageSize: 50 }).then(r => getPaginatedItems(r)),
|
||||||
[], 'Failed to load low-stock products')
|
[], 'Failed to load low-stock products')
|
||||||
const { data: movers } = useLoad<ProductSalesRow[]>(
|
const { data: movers, loading: moversLoading } = useLoad<ProductSalesRow[]>(
|
||||||
() => productReports.sales({ ...range, pageSize: 50 }).then(r => getPaginatedItems(r)),
|
() => productReports.sales({ ...range, pageSize: 50 }).then(r => getPaginatedItems(r)),
|
||||||
[range.from, range.to], 'Failed to load product sales')
|
[range.from, range.to], 'Failed to load product sales')
|
||||||
|
|
||||||
if (loading || !inv || !overview) return <LoadingGrid />
|
if (loading || moversLoading || !inv || !overview) return <LoadingGrid />
|
||||||
|
|
||||||
const healthy = Math.max(0, overview.totalSkus - overview.lowStockCount - overview.outOfStockCount)
|
const healthy = Math.max(0, overview.totalSkus - overview.lowStockCount - overview.outOfStockCount)
|
||||||
const sold = (movers ?? []).map(r => ({ name: r.name, value: r.qtyFulfilled }))
|
const sold = (movers ?? []).map(r => ({ name: r.name, value: r.qtyFulfilled }))
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useParams, useRouter } from 'next/navigation'
|
||||||
|
import { HugeiconsIcon } from '@hugeicons/react'
|
||||||
|
import { ArrowLeft01Icon } from '@hugeicons/core-free-icons'
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { toast } from '@/components/ui/toast'
|
||||||
|
import { roles as rolesApi, getApiError } from '@/lib/api/index'
|
||||||
|
import type { RoleResponse } from '@/types/api'
|
||||||
|
|
||||||
|
import { RoleForm } from '../../_components/role-form'
|
||||||
|
|
||||||
|
export default function EditRolePage() {
|
||||||
|
const { id } = useParams<{ id: string }>()
|
||||||
|
const router = useRouter()
|
||||||
|
const goBack = () => router.push('/users/roles')
|
||||||
|
|
||||||
|
const [role, setRole] = useState<RoleResponse | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
rolesApi.get(id)
|
||||||
|
.then(setRole)
|
||||||
|
.catch((err) => toast.add({ title: 'Failed to load role', description: getApiError(err), type: 'error' }))
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [id])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6 w-full">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button variant="ghost" size="icon" onClick={goBack} aria-label="Back to roles">
|
||||||
|
<HugeiconsIcon icon={ArrowLeft01Icon} size={16} />
|
||||||
|
</Button>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold text-foreground">Edit Role</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mt-0.5">Update role details and permissions.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border bg-card p-6">
|
||||||
|
{loading && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{Array.from({ length: 4 }).map((_, i) => (
|
||||||
|
<div key={i} className="h-10 animate-pulse rounded-xl bg-muted" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!loading && role && <RoleForm role={role} onSuccess={goBack} onCancel={goBack} wide />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useForm, zodResolver, z } from '@/lib/form'
|
||||||
|
import { FormField } from '@/components/shared/form-field'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
|
import {
|
||||||
|
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { FieldGroup } from '@/components/ui/field'
|
||||||
|
import { toast } from '@/components/ui/toast'
|
||||||
|
import { roles as rolesApi, permissions as permissionsApi, getApiError } from '@/lib/api/index'
|
||||||
|
import type { RoleResponse, PermissionResponse } from '@/types/api'
|
||||||
|
import { PERMISSION_CATEGORIES } from '@/config/permissions'
|
||||||
|
|
||||||
|
const ROLE_LEVELS: { value: number; label: string; description: string }[] = [
|
||||||
|
{ value: 10, label: 'Level 10 — Field Agent', description: 'Front-line sales rep; can place orders and check in to visits' },
|
||||||
|
{ value: 20, label: 'Level 20 — Supervisor', description: 'Oversees agents; can approve variances and update store credit limits' },
|
||||||
|
{ value: 30, label: 'Level 30 — Area Manager', description: 'Manages a group of supervisors within an area' },
|
||||||
|
{ value: 40, label: 'Level 40 — Area Supervisor', description: 'Senior oversight across multiple areas' },
|
||||||
|
{ value: 50, label: 'Level 50 — Regional Manager', description: 'Controls region targets, budgets and route assignments' },
|
||||||
|
{ value: 60, label: 'Level 60 — Regional Agency', description: 'Agency-level access across any region; broader scope than Regional Manager' },
|
||||||
|
{ value: 100, label: 'Level 100 — Super Admin', description: 'Full system access; all permissions granted' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
name: z.string().min(1, 'Name is required'),
|
||||||
|
level: z.string().min(1, 'Level is required'),
|
||||||
|
})
|
||||||
|
|
||||||
|
type FormValues = z.infer<typeof schema>
|
||||||
|
|
||||||
|
// 'orders.view' -> 'View Orders', 'loading-sheet.manage' -> 'Manage Loading Sheet'
|
||||||
|
function permissionLabel(code: string): string {
|
||||||
|
const [resource, action] = code.split('.')
|
||||||
|
const title = (s: string) => s.split(/[-_]/).map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(' ')
|
||||||
|
return action ? `${title(action)} ${title(resource)}` : title(resource)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RoleFormProps {
|
||||||
|
role: RoleResponse | null
|
||||||
|
onSuccess: () => void
|
||||||
|
onCancel: () => void
|
||||||
|
wide?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RoleForm({ role, onSuccess, onCancel, wide = false }: RoleFormProps) {
|
||||||
|
const isEdit = !!role
|
||||||
|
|
||||||
|
const [allPermissions, setAllPermissions] = useState<PermissionResponse[]>([])
|
||||||
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||||
|
|
||||||
|
const form = useForm<FormValues>({
|
||||||
|
resolver: zodResolver(schema),
|
||||||
|
defaultValues: { name: '', level: '' },
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
permissionsApi.list().then(setAllPermissions).catch(() => {})
|
||||||
|
if (role) {
|
||||||
|
form.reset({ name: role.name, level: String(role.level) })
|
||||||
|
rolesApi.getPermissions(role.id)
|
||||||
|
.then((perms) => setSelectedIds(new Set(perms.map((p) => p.permissionId))))
|
||||||
|
.catch(() => {})
|
||||||
|
} else {
|
||||||
|
form.reset({ name: '', level: '' })
|
||||||
|
setSelectedIds(new Set())
|
||||||
|
}
|
||||||
|
}, [role])
|
||||||
|
|
||||||
|
function togglePermission(id: string) {
|
||||||
|
setSelectedIds((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
next.has(id) ? next.delete(id) : next.add(id)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSubmit(values: FormValues) {
|
||||||
|
const body = { name: values.name, level: parseInt(values.level, 10) }
|
||||||
|
try {
|
||||||
|
let roleId: string
|
||||||
|
if (isEdit) {
|
||||||
|
await rolesApi.update(role!.id, body)
|
||||||
|
roleId = role!.id
|
||||||
|
toast.add({ title: 'Role updated', type: 'success' })
|
||||||
|
} else {
|
||||||
|
const created = await rolesApi.create(body)
|
||||||
|
roleId = created.id
|
||||||
|
toast.add({ title: 'Role created', type: 'success' })
|
||||||
|
}
|
||||||
|
await rolesApi.assignPermissions(roleId, Array.from(selectedIds))
|
||||||
|
onSuccess()
|
||||||
|
} catch (err) {
|
||||||
|
toast.add({ title: 'Failed to save role', description: getApiError(err), type: 'error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-6">
|
||||||
|
<FieldGroup className={wide ? 'grid grid-cols-1 gap-6 md:grid-cols-2' : undefined}>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="name"
|
||||||
|
label="Role Name"
|
||||||
|
required
|
||||||
|
render={({ field, invalid }) => (
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
name={field.name}
|
||||||
|
ref={field.ref}
|
||||||
|
value={field.value as string}
|
||||||
|
onChange={field.onChange}
|
||||||
|
onBlur={field.onBlur}
|
||||||
|
placeholder="e.g. Sales Rep"
|
||||||
|
aria-invalid={invalid}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="level"
|
||||||
|
label="Level"
|
||||||
|
required
|
||||||
|
render={({ field }) => (
|
||||||
|
<Select value={field.value as string} onValueChange={field.onChange}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select level" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{ROLE_LEVELS.map((l) => (
|
||||||
|
<SelectItem key={l.value} value={String(l.value)}>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-medium">{l.label}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">{l.description}</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</FieldGroup>
|
||||||
|
|
||||||
|
{allPermissions.length > 0 && (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<p className="text-sm font-medium">Permissions</p>
|
||||||
|
<div className={wide ? 'border rounded-md p-4' : 'border rounded-md max-h-[28rem] overflow-y-auto p-3'}>
|
||||||
|
<div className={wide ? 'flex flex-col gap-3' :'grid grid-cols-2 gap-3'}>
|
||||||
|
{PERMISSION_CATEGORIES.map((cat) => {
|
||||||
|
const catPerms = allPermissions.filter((p) => cat.codes.includes(p.code))
|
||||||
|
if (catPerms.length === 0) return null
|
||||||
|
const allChecked = catPerms.every((p) => selectedIds.has(p.permissionId))
|
||||||
|
const someChecked = catPerms.some((p) => selectedIds.has(p.permissionId))
|
||||||
|
return (
|
||||||
|
<div key={cat.id} className="rounded-md border bg-muted/30 p-3 flex flex-col gap-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id={`cat-${cat.id}`}
|
||||||
|
checked={allChecked}
|
||||||
|
data-state={someChecked && !allChecked ? 'indeterminate' : undefined}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
setSelectedIds((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
catPerms.forEach((p) => checked ? next.add(p.permissionId) : next.delete(p.permissionId))
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<label htmlFor={`cat-${cat.id}`} className="text-xs font-semibold uppercase tracking-wide text-muted-foreground cursor-pointer select-none">
|
||||||
|
{cat.label}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className={wide ? 'flex flex-row flex-wrap gap-x-6 gap-y-2 pl-6' : 'flex flex-col gap-1.5 pl-6'}>
|
||||||
|
{catPerms.map((p) => (
|
||||||
|
<div key={p.permissionId} className="flex items-start gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id={p.permissionId}
|
||||||
|
checked={selectedIds.has(p.permissionId)}
|
||||||
|
onCheckedChange={() => togglePermission(p.permissionId)}
|
||||||
|
className="mt-0.5"
|
||||||
|
/>
|
||||||
|
<label htmlFor={p.permissionId} className="text-sm cursor-pointer flex flex-col">
|
||||||
|
<span className="font-medium">{wide ? permissionLabel(p.code) : p.code}</span>
|
||||||
|
{!wide && p.description && <span className="text-muted-foreground text-xs">{p.description}</span>}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button type="button" variant="outline" onClick={onCancel}>Cancel</Button>
|
||||||
|
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||||
|
{form.formState.isSubmitting ? 'Saving…' : isEdit ? 'Save Changes' : 'Create Role'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,217 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
|
||||||
import { useForm, zodResolver, z } from '@/lib/form'
|
|
||||||
import { FormField } from '@/components/shared/form-field'
|
|
||||||
import { Input } from '@/components/ui/input'
|
|
||||||
import { Checkbox } from '@/components/ui/checkbox'
|
|
||||||
import {
|
|
||||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
|
||||||
} from '@/components/ui/select'
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import {
|
|
||||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter,
|
|
||||||
} from '@/components/ui/dialog'
|
|
||||||
import { FieldGroup } from '@/components/ui/field'
|
|
||||||
import { toast } from '@/components/ui/toast'
|
|
||||||
import { roles as rolesApi, permissions as permissionsApi, getApiError } from '@/lib/api/index'
|
|
||||||
import type { RoleResponse, PermissionResponse } from '@/types/api'
|
|
||||||
import { PERMISSION_CATEGORIES } from '@/config/permissions'
|
|
||||||
|
|
||||||
const ROLE_LEVELS: { value: number; label: string; description: string }[] = [
|
|
||||||
{ value: 10, label: 'Level 10 — Field Agent', description: 'Front-line sales rep; can place orders and check in to visits' },
|
|
||||||
{ value: 20, label: 'Level 20 — Supervisor', description: 'Oversees agents; can approve variances and update store credit limits' },
|
|
||||||
{ value: 30, label: 'Level 30 — Area Manager', description: 'Manages a group of supervisors within an area' },
|
|
||||||
{ value: 40, label: 'Level 40 — Area Supervisor', description: 'Senior oversight across multiple areas' },
|
|
||||||
{ value: 50, label: 'Level 50 — Regional Manager', description: 'Controls region targets, budgets and route assignments' },
|
|
||||||
{ value: 60, label: 'Level 60 — Regional Agency', description: 'Agency-level access across any region; broader scope than Regional Manager' },
|
|
||||||
{ value: 100, label: 'Level 100 — Super Admin', description: 'Full system access; all permissions granted' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const schema = z.object({
|
|
||||||
name: z.string().min(1, 'Name is required'),
|
|
||||||
level: z.string().min(1, 'Level is required'),
|
|
||||||
})
|
|
||||||
|
|
||||||
type FormValues = z.infer<typeof schema>
|
|
||||||
|
|
||||||
interface RoleSheetProps {
|
|
||||||
open: boolean
|
|
||||||
onClose: () => void
|
|
||||||
role: RoleResponse | null
|
|
||||||
onSuccess: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function RoleSheet({ open, onClose, role, onSuccess }: RoleSheetProps) {
|
|
||||||
const isEdit = !!role
|
|
||||||
|
|
||||||
const [allPermissions, setAllPermissions] = useState<PermissionResponse[]>([])
|
|
||||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
|
||||||
|
|
||||||
const form = useForm<FormValues>({
|
|
||||||
resolver: zodResolver(schema),
|
|
||||||
defaultValues: { name: '', level: '' },
|
|
||||||
})
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) return
|
|
||||||
permissionsApi.list().then(setAllPermissions).catch(() => {})
|
|
||||||
if (role) {
|
|
||||||
form.reset({ name: role.name, level: String(role.level) })
|
|
||||||
rolesApi.getPermissions(role.id)
|
|
||||||
.then((perms) => setSelectedIds(new Set(perms.map((p) => p.permissionId))))
|
|
||||||
.catch(() => {})
|
|
||||||
} else {
|
|
||||||
form.reset({ name: '', level: '' })
|
|
||||||
setSelectedIds(new Set())
|
|
||||||
}
|
|
||||||
}, [open, role])
|
|
||||||
|
|
||||||
function togglePermission(id: string) {
|
|
||||||
setSelectedIds((prev) => {
|
|
||||||
const next = new Set(prev)
|
|
||||||
next.has(id) ? next.delete(id) : next.add(id)
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onSubmit(values: FormValues) {
|
|
||||||
const body = { name: values.name, level: parseInt(values.level, 10) }
|
|
||||||
try {
|
|
||||||
let roleId: string
|
|
||||||
if (isEdit) {
|
|
||||||
await rolesApi.update(role!.id, body)
|
|
||||||
roleId = role!.id
|
|
||||||
toast.add({ title: 'Role updated', type: 'success' })
|
|
||||||
} else {
|
|
||||||
const created = await rolesApi.create(body)
|
|
||||||
roleId = created.id
|
|
||||||
toast.add({ title: 'Role created', type: 'success' })
|
|
||||||
}
|
|
||||||
await rolesApi.assignPermissions(roleId, Array.from(selectedIds))
|
|
||||||
onSuccess()
|
|
||||||
onClose()
|
|
||||||
} catch (err) {
|
|
||||||
toast.add({ title: 'Failed to save role', description: getApiError(err), type: 'error' })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
|
||||||
<DialogContent className="overflow-y-auto sm:max-w-3xl">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>{isEdit ? 'Edit Role' : 'Add Role'}</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
{isEdit ? 'Update role details and permissions.' : 'Create a new role and assign permissions.'}
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-6">
|
|
||||||
<FieldGroup>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="name"
|
|
||||||
label="Role Name"
|
|
||||||
required
|
|
||||||
render={({ field, invalid }) => (
|
|
||||||
<Input
|
|
||||||
id="name"
|
|
||||||
name={field.name}
|
|
||||||
ref={field.ref}
|
|
||||||
value={field.value as string}
|
|
||||||
onChange={field.onChange}
|
|
||||||
onBlur={field.onBlur}
|
|
||||||
placeholder="e.g. Sales Rep"
|
|
||||||
aria-invalid={invalid}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="level"
|
|
||||||
label="Level"
|
|
||||||
required
|
|
||||||
render={({ field }) => (
|
|
||||||
<Select value={field.value as string} onValueChange={field.onChange}>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Select level" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{ROLE_LEVELS.map((l) => (
|
|
||||||
<SelectItem key={l.value} value={String(l.value)}>
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span className="font-medium">{l.label}</span>
|
|
||||||
<span className="text-xs text-muted-foreground">{l.description}</span>
|
|
||||||
</div>
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</FieldGroup>
|
|
||||||
|
|
||||||
{allPermissions.length > 0 && (
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<p className="text-sm font-medium">Permissions</p>
|
|
||||||
<div className="border rounded-md max-h-[28rem] overflow-y-auto p-3">
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
{PERMISSION_CATEGORIES.map((cat) => {
|
|
||||||
const catPerms = allPermissions.filter((p) => cat.codes.includes(p.code))
|
|
||||||
if (catPerms.length === 0) return null
|
|
||||||
const allChecked = catPerms.every((p) => selectedIds.has(p.permissionId))
|
|
||||||
const someChecked = catPerms.some((p) => selectedIds.has(p.permissionId))
|
|
||||||
return (
|
|
||||||
<div key={cat.id} className="rounded-md border bg-muted/30 p-3 flex flex-col gap-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Checkbox
|
|
||||||
id={`cat-${cat.id}`}
|
|
||||||
checked={allChecked}
|
|
||||||
data-state={someChecked && !allChecked ? 'indeterminate' : undefined}
|
|
||||||
onCheckedChange={(checked) => {
|
|
||||||
setSelectedIds((prev) => {
|
|
||||||
const next = new Set(prev)
|
|
||||||
catPerms.forEach((p) => checked ? next.add(p.permissionId) : next.delete(p.permissionId))
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<label htmlFor={`cat-${cat.id}`} className="text-xs font-semibold uppercase tracking-wide text-muted-foreground cursor-pointer select-none">
|
|
||||||
{cat.label}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1.5 pl-6">
|
|
||||||
{catPerms.map((p) => (
|
|
||||||
<div key={p.permissionId} className="flex items-start gap-2">
|
|
||||||
<Checkbox
|
|
||||||
id={p.permissionId}
|
|
||||||
checked={selectedIds.has(p.permissionId)}
|
|
||||||
onCheckedChange={() => togglePermission(p.permissionId)}
|
|
||||||
className="mt-0.5"
|
|
||||||
/>
|
|
||||||
<label htmlFor={p.permissionId} className="text-sm cursor-pointer flex flex-col">
|
|
||||||
<span className="font-medium">{p.code}</span>
|
|
||||||
{p.description && <span className="text-muted-foreground text-xs">{p.description}</span>}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button type="button" variant="outline" onClick={onClose}>Cancel</Button>
|
|
||||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
|
||||||
{form.formState.isSubmitting ? 'Saving…' : isEdit ? 'Save Changes' : 'Create Role'}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useEffect, useCallback, useMemo } from 'react'
|
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
import { HugeiconsIcon } from '@hugeicons/react'
|
import { HugeiconsIcon } from '@hugeicons/react'
|
||||||
import { Add01Icon } from '@hugeicons/core-free-icons'
|
import { Add01Icon } from '@hugeicons/core-free-icons'
|
||||||
|
|
||||||
@@ -12,17 +13,15 @@ import { toast } from '@/components/ui/toast'
|
|||||||
import { roles as rolesApi, getApiError } from '@/lib/api/index'
|
import { roles as rolesApi, getApiError } from '@/lib/api/index'
|
||||||
import type { RoleResponse } from '@/types/api'
|
import type { RoleResponse } from '@/types/api'
|
||||||
|
|
||||||
import { RoleSheet } from './role-sheet'
|
|
||||||
import { RoleViewSheet } from './role-view-sheet'
|
import { RoleViewSheet } from './role-view-sheet'
|
||||||
|
|
||||||
export function RolesClient() {
|
export function RolesClient() {
|
||||||
|
const router = useRouter()
|
||||||
const [data, setData] = useState<RoleResponse[]>([])
|
const [data, setData] = useState<RoleResponse[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [total, setTotal] = useState(0)
|
const [total, setTotal] = useState(0)
|
||||||
const [pageSize, setPageSize] = useState(20)
|
const [pageSize, setPageSize] = useState(20)
|
||||||
|
|
||||||
const [sheetOpen, setSheetOpen] = useState(false)
|
|
||||||
const [editing, setEditing] = useState<RoleResponse | null>(null)
|
|
||||||
const [viewOpen, setViewOpen] = useState(false)
|
const [viewOpen, setViewOpen] = useState(false)
|
||||||
const [viewing, setViewing] = useState<RoleResponse | null>(null)
|
const [viewing, setViewing] = useState<RoleResponse | null>(null)
|
||||||
|
|
||||||
@@ -41,8 +40,8 @@ export function RolesClient() {
|
|||||||
|
|
||||||
useEffect(() => { fetchData() }, [fetchData])
|
useEffect(() => { fetchData() }, [fetchData])
|
||||||
|
|
||||||
function openCreate() { setEditing(null); setSheetOpen(true) }
|
function openCreate() { router.push('/users/roles/new') }
|
||||||
function openEdit(role: RoleResponse) { setEditing(role); setSheetOpen(true) }
|
function openEdit(role: RoleResponse) { router.push(`/users/roles/${role.id}/edit`) }
|
||||||
function openView(role: RoleResponse) { setViewing(role); setViewOpen(true) }
|
function openView(role: RoleResponse) { setViewing(role); setViewOpen(true) }
|
||||||
|
|
||||||
async function handleDeactivate(role: RoleResponse) {
|
async function handleDeactivate(role: RoleResponse) {
|
||||||
@@ -122,13 +121,6 @@ export function RolesClient() {
|
|||||||
onServerSideChange={({ page: p }) => setPageSize(p.pageSize)}
|
onServerSideChange={({ page: p }) => setPageSize(p.pageSize)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<RoleSheet
|
|
||||||
open={sheetOpen}
|
|
||||||
onClose={() => setSheetOpen(false)}
|
|
||||||
role={editing}
|
|
||||||
onSuccess={() => fetchData()}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<RoleViewSheet
|
<RoleViewSheet
|
||||||
open={viewOpen}
|
open={viewOpen}
|
||||||
onClose={() => setViewOpen(false)}
|
onClose={() => setViewOpen(false)}
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import { HugeiconsIcon } from '@hugeicons/react'
|
||||||
|
import { ArrowLeft01Icon } from '@hugeicons/core-free-icons'
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
|
||||||
|
import { RoleForm } from '../_components/role-form'
|
||||||
|
|
||||||
|
export default function NewRolePage() {
|
||||||
|
const router = useRouter()
|
||||||
|
const goBack = () => router.push('/users/roles')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6 w-full">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button variant="ghost" size="icon" onClick={goBack} aria-label="Back to roles">
|
||||||
|
<HugeiconsIcon icon={ArrowLeft01Icon} size={16} />
|
||||||
|
</Button>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold text-foreground">Add Role</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mt-0.5">Create a new role and assign permissions.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border bg-card p-6">
|
||||||
|
<RoleForm role={null} onSuccess={goBack} onCancel={goBack} wide />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user