feat: add authenticated settings page.
This commit is contained in:
@@ -0,0 +1,139 @@
|
||||
import { useState } from 'react'
|
||||
import { type Table } from '@tanstack/react-table'
|
||||
import { Trash2, UserX, UserCheck, Mail } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { sleep } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import { DataTableBulkActions as BulkActionsToolbar } from '@/components/data-table'
|
||||
import { type User } from '../data/schema'
|
||||
import { UsersMultiDeleteDialog } from './users-multi-delete-dialog'
|
||||
|
||||
type DataTableBulkActionsProps<TData> = {
|
||||
table: Table<TData>
|
||||
}
|
||||
|
||||
export function DataTableBulkActions<TData>({
|
||||
table,
|
||||
}: DataTableBulkActionsProps<TData>) {
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||
const selectedRows = table.getFilteredSelectedRowModel().rows
|
||||
|
||||
const handleBulkStatusChange = (status: 'active' | 'inactive') => {
|
||||
const selectedUsers = selectedRows.map((row) => row.original as User)
|
||||
toast.promise(sleep(2000), {
|
||||
loading: `${status === 'active' ? 'Activating' : 'Deactivating'} users...`,
|
||||
success: () => {
|
||||
table.resetRowSelection()
|
||||
return `${status === 'active' ? 'Activated' : 'Deactivated'} ${selectedUsers.length} user${selectedUsers.length > 1 ? 's' : ''}`
|
||||
},
|
||||
error: `Error ${status === 'active' ? 'activating' : 'deactivating'} users`,
|
||||
})
|
||||
table.resetRowSelection()
|
||||
}
|
||||
|
||||
const handleBulkInvite = () => {
|
||||
const selectedUsers = selectedRows.map((row) => row.original as User)
|
||||
toast.promise(sleep(2000), {
|
||||
loading: 'Inviting users...',
|
||||
success: () => {
|
||||
table.resetRowSelection()
|
||||
return `Invited ${selectedUsers.length} user${selectedUsers.length > 1 ? 's' : ''}`
|
||||
},
|
||||
error: 'Error inviting users',
|
||||
})
|
||||
table.resetRowSelection()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<BulkActionsToolbar table={table} entityName='user'>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='icon'
|
||||
onClick={handleBulkInvite}
|
||||
className='size-8'
|
||||
aria-label='Invite selected users'
|
||||
title='Invite selected users'
|
||||
>
|
||||
<Mail />
|
||||
<span className='sr-only'>Invite selected users</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Invite selected users</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='icon'
|
||||
onClick={() => handleBulkStatusChange('active')}
|
||||
className='size-8'
|
||||
aria-label='Activate selected users'
|
||||
title='Activate selected users'
|
||||
>
|
||||
<UserCheck />
|
||||
<span className='sr-only'>Activate selected users</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Activate selected users</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='icon'
|
||||
onClick={() => handleBulkStatusChange('inactive')}
|
||||
className='size-8'
|
||||
aria-label='Deactivate selected users'
|
||||
title='Deactivate selected users'
|
||||
>
|
||||
<UserX />
|
||||
<span className='sr-only'>Deactivate selected users</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Deactivate selected users</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant='destructive'
|
||||
size='icon'
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
className='size-8'
|
||||
aria-label='Delete selected users'
|
||||
title='Delete selected users'
|
||||
>
|
||||
<Trash2 />
|
||||
<span className='sr-only'>Delete selected users</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Delete selected users</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</BulkActionsToolbar>
|
||||
|
||||
<UsersMultiDeleteDialog
|
||||
table={table}
|
||||
open={showDeleteConfirm}
|
||||
onOpenChange={setShowDeleteConfirm}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { DotsHorizontalIcon } from '@radix-ui/react-icons'
|
||||
import { type Row } from '@tanstack/react-table'
|
||||
import { Trash2, UserPen } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { type User } from '../data/schema'
|
||||
import { useUsers } from './users-provider'
|
||||
|
||||
type DataTableRowActionsProps = {
|
||||
row: Row<User>
|
||||
}
|
||||
|
||||
export function DataTableRowActions({ row }: DataTableRowActionsProps) {
|
||||
const { setOpen, setCurrentRow } = useUsers()
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
className='flex h-8 w-8 p-0 data-[state=open]:bg-muted'
|
||||
>
|
||||
<DotsHorizontalIcon className='h-4 w-4' />
|
||||
<span className='sr-only'>Open menu</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end' className='w-[160px]'>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setCurrentRow(row.original)
|
||||
setOpen('edit')
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
<DropdownMenuShortcut>
|
||||
<UserPen size={16} />
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setCurrentRow(row.original)
|
||||
setOpen('delete')
|
||||
}}
|
||||
className='text-red-500!'
|
||||
>
|
||||
Delete
|
||||
<DropdownMenuShortcut>
|
||||
<Trash2 size={16} />
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
'use client'
|
||||
|
||||
import { z } from 'zod'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { showSubmittedData } from '@/lib/show-submitted-data'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { PasswordInput } from '@/components/password-input'
|
||||
import { SelectDropdown } from '@/components/select-dropdown'
|
||||
import { roles } from '../data/data'
|
||||
import { type User } from '../data/schema'
|
||||
|
||||
const formSchema = z
|
||||
.object({
|
||||
firstName: z.string().min(1, 'First Name is required.'),
|
||||
lastName: z.string().min(1, 'Last Name is required.'),
|
||||
username: z.string().min(1, 'Username is required.'),
|
||||
phoneNumber: z.string().min(1, 'Phone number is required.'),
|
||||
email: z.email({
|
||||
error: (iss) => (iss.input === '' ? 'Email is required.' : undefined),
|
||||
}),
|
||||
password: z.string().transform((pwd) => pwd.trim()),
|
||||
role: z.string().min(1, 'Role is required.'),
|
||||
confirmPassword: z.string().transform((pwd) => pwd.trim()),
|
||||
isEdit: z.boolean(),
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
if (data.isEdit && !data.password) return true
|
||||
return data.password.length > 0
|
||||
},
|
||||
{
|
||||
message: 'Password is required.',
|
||||
path: ['password'],
|
||||
}
|
||||
)
|
||||
.refine(
|
||||
({ isEdit, password }) => {
|
||||
if (isEdit && !password) return true
|
||||
return password.length >= 8
|
||||
},
|
||||
{
|
||||
message: 'Password must be at least 8 characters long.',
|
||||
path: ['password'],
|
||||
}
|
||||
)
|
||||
.refine(
|
||||
({ isEdit, password }) => {
|
||||
if (isEdit && !password) return true
|
||||
return /[a-z]/.test(password)
|
||||
},
|
||||
{
|
||||
message: 'Password must contain at least one lowercase letter.',
|
||||
path: ['password'],
|
||||
}
|
||||
)
|
||||
.refine(
|
||||
({ isEdit, password }) => {
|
||||
if (isEdit && !password) return true
|
||||
return /\d/.test(password)
|
||||
},
|
||||
{
|
||||
message: 'Password must contain at least one number.',
|
||||
path: ['password'],
|
||||
}
|
||||
)
|
||||
.refine(
|
||||
({ isEdit, password, confirmPassword }) => {
|
||||
if (isEdit && !password) return true
|
||||
return password === confirmPassword
|
||||
},
|
||||
{
|
||||
message: "Passwords don't match.",
|
||||
path: ['confirmPassword'],
|
||||
}
|
||||
)
|
||||
type UserForm = z.infer<typeof formSchema>
|
||||
|
||||
type UserActionDialogProps = {
|
||||
currentRow?: User
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function UsersActionDialog({
|
||||
currentRow,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: UserActionDialogProps) {
|
||||
const isEdit = !!currentRow
|
||||
const form = useForm<UserForm>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: isEdit
|
||||
? {
|
||||
...currentRow,
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
isEdit,
|
||||
}
|
||||
: {
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
username: '',
|
||||
email: '',
|
||||
role: '',
|
||||
phoneNumber: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
isEdit,
|
||||
},
|
||||
})
|
||||
|
||||
const onSubmit = (values: UserForm) => {
|
||||
form.reset()
|
||||
showSubmittedData(values)
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
const isPasswordTouched = !!form.formState.dirtyFields.password
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(state) => {
|
||||
form.reset()
|
||||
onOpenChange(state)
|
||||
}}
|
||||
>
|
||||
<DialogContent className='sm:max-w-lg'>
|
||||
<DialogHeader className='text-start'>
|
||||
<DialogTitle>{isEdit ? 'Edit User' : 'Add New User'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isEdit ? 'Update the user here. ' : 'Create new user here. '}
|
||||
Click save when you're done.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className='h-105 w-[calc(100%+0.75rem)] overflow-y-auto py-1 pe-3'>
|
||||
<Form {...form}>
|
||||
<form
|
||||
id='user-form'
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className='space-y-4 px-0.5'
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='firstName'
|
||||
render={({ field }) => (
|
||||
<FormItem className='grid grid-cols-6 items-center space-y-0 gap-x-4 gap-y-1'>
|
||||
<FormLabel className='col-span-2 text-end'>
|
||||
First Name
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder='John'
|
||||
className='col-span-4'
|
||||
autoComplete='off'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage className='col-span-4 col-start-3' />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='lastName'
|
||||
render={({ field }) => (
|
||||
<FormItem className='grid grid-cols-6 items-center space-y-0 gap-x-4 gap-y-1'>
|
||||
<FormLabel className='col-span-2 text-end'>
|
||||
Last Name
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder='Doe'
|
||||
className='col-span-4'
|
||||
autoComplete='off'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage className='col-span-4 col-start-3' />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='username'
|
||||
render={({ field }) => (
|
||||
<FormItem className='grid grid-cols-6 items-center space-y-0 gap-x-4 gap-y-1'>
|
||||
<FormLabel className='col-span-2 text-end'>
|
||||
Username
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder='john_doe'
|
||||
className='col-span-4'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage className='col-span-4 col-start-3' />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='email'
|
||||
render={({ field }) => (
|
||||
<FormItem className='grid grid-cols-6 items-center space-y-0 gap-x-4 gap-y-1'>
|
||||
<FormLabel className='col-span-2 text-end'>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder='john.doe@gmail.com'
|
||||
className='col-span-4'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage className='col-span-4 col-start-3' />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='phoneNumber'
|
||||
render={({ field }) => (
|
||||
<FormItem className='grid grid-cols-6 items-center space-y-0 gap-x-4 gap-y-1'>
|
||||
<FormLabel className='col-span-2 text-end'>
|
||||
Phone Number
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder='+123456789'
|
||||
className='col-span-4'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage className='col-span-4 col-start-3' />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='role'
|
||||
render={({ field }) => (
|
||||
<FormItem className='grid grid-cols-6 items-center space-y-0 gap-x-4 gap-y-1'>
|
||||
<FormLabel className='col-span-2 text-end'>Role</FormLabel>
|
||||
<SelectDropdown
|
||||
defaultValue={field.value}
|
||||
onValueChange={field.onChange}
|
||||
placeholder='Select a role'
|
||||
className='col-span-4'
|
||||
items={roles.map(({ label, value }) => ({
|
||||
label,
|
||||
value,
|
||||
}))}
|
||||
/>
|
||||
<FormMessage className='col-span-4 col-start-3' />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='password'
|
||||
render={({ field }) => (
|
||||
<FormItem className='grid grid-cols-6 items-center space-y-0 gap-x-4 gap-y-1'>
|
||||
<FormLabel className='col-span-2 text-end'>
|
||||
Password
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput
|
||||
placeholder='e.g., S3cur3P@ssw0rd'
|
||||
className='col-span-4'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage className='col-span-4 col-start-3' />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='confirmPassword'
|
||||
render={({ field }) => (
|
||||
<FormItem className='grid grid-cols-6 items-center space-y-0 gap-x-4 gap-y-1'>
|
||||
<FormLabel className='col-span-2 text-end'>
|
||||
Confirm Password
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput
|
||||
disabled={!isPasswordTouched}
|
||||
placeholder='e.g., S3cur3P@ssw0rd'
|
||||
className='col-span-4'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage className='col-span-4 col-start-3' />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type='submit' form='user-form'>
|
||||
Save changes
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
138
shadcn-admin/src/features/users/components/users-columns.tsx
Normal file
138
shadcn-admin/src/features/users/components/users-columns.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import { type ColumnDef } from '@tanstack/react-table'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { DataTableColumnHeader } from '@/components/data-table'
|
||||
import { LongText } from '@/components/long-text'
|
||||
import { callTypes, roles } from '../data/data'
|
||||
import { type User } from '../data/schema'
|
||||
import { DataTableRowActions } from './data-table-row-actions'
|
||||
|
||||
export const usersColumns: ColumnDef<User>[] = [
|
||||
{
|
||||
id: 'select',
|
||||
header: ({ table }) => (
|
||||
<Checkbox
|
||||
checked={
|
||||
table.getIsAllPageRowsSelected() ||
|
||||
(table.getIsSomePageRowsSelected() && 'indeterminate')
|
||||
}
|
||||
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
||||
aria-label='Select all'
|
||||
className='translate-y-[2px]'
|
||||
/>
|
||||
),
|
||||
meta: {
|
||||
className: cn('max-md:sticky start-0 z-10 rounded-tl-[inherit]'),
|
||||
},
|
||||
cell: ({ row }) => (
|
||||
<Checkbox
|
||||
checked={row.getIsSelected()}
|
||||
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||
aria-label='Select row'
|
||||
className='translate-y-[2px]'
|
||||
/>
|
||||
),
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
},
|
||||
{
|
||||
accessorKey: 'username',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title='Username' />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<LongText className='max-w-36 ps-3'>{row.getValue('username')}</LongText>
|
||||
),
|
||||
meta: {
|
||||
className: cn(
|
||||
'drop-shadow-[0_1px_2px_rgb(0_0_0_/_0.1)] dark:drop-shadow-[0_1px_2px_rgb(255_255_255_/_0.1)]',
|
||||
'ps-0.5 max-md:sticky start-6 @4xl/content:table-cell @4xl/content:drop-shadow-none'
|
||||
),
|
||||
},
|
||||
enableHiding: false,
|
||||
},
|
||||
{
|
||||
id: 'fullName',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title='Name' />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const { firstName, lastName } = row.original
|
||||
const fullName = `${firstName} ${lastName}`
|
||||
return <LongText className='max-w-36'>{fullName}</LongText>
|
||||
},
|
||||
meta: { className: 'w-36' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'email',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title='Email' />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<div className='w-fit ps-2 text-nowrap'>{row.getValue('email')}</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'phoneNumber',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title='Phone Number' />
|
||||
),
|
||||
cell: ({ row }) => <div>{row.getValue('phoneNumber')}</div>,
|
||||
enableSorting: false,
|
||||
},
|
||||
{
|
||||
accessorKey: 'status',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title='Status' />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const { status } = row.original
|
||||
const badgeColor = callTypes.get(status)
|
||||
return (
|
||||
<div className='flex space-x-2'>
|
||||
<Badge variant='outline' className={cn('capitalize', badgeColor)}>
|
||||
{row.getValue('status')}
|
||||
</Badge>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
filterFn: (row, id, value) => {
|
||||
return value.includes(row.getValue(id))
|
||||
},
|
||||
enableHiding: false,
|
||||
enableSorting: false,
|
||||
},
|
||||
{
|
||||
accessorKey: 'role',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title='Role' />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const { role } = row.original
|
||||
const userType = roles.find(({ value }) => value === role)
|
||||
|
||||
if (!userType) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex items-center gap-x-2'>
|
||||
{userType.icon && (
|
||||
<userType.icon size={16} className='text-muted-foreground' />
|
||||
)}
|
||||
<span className='text-sm capitalize'>{row.getValue('role')}</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
filterFn: (row, id, value) => {
|
||||
return value.includes(row.getValue(id))
|
||||
},
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
cell: DataTableRowActions,
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,81 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { AlertTriangle } from 'lucide-react'
|
||||
import { showSubmittedData } from '@/lib/show-submitted-data'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { type User } from '../data/schema'
|
||||
|
||||
type UserDeleteDialogProps = {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
currentRow: User
|
||||
}
|
||||
|
||||
export function UsersDeleteDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
currentRow,
|
||||
}: UserDeleteDialogProps) {
|
||||
const [value, setValue] = useState('')
|
||||
|
||||
const handleDelete = () => {
|
||||
if (value.trim() !== currentRow.username) return
|
||||
|
||||
onOpenChange(false)
|
||||
showSubmittedData(currentRow, 'The following user has been deleted:')
|
||||
}
|
||||
|
||||
return (
|
||||
<ConfirmDialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
handleConfirm={handleDelete}
|
||||
disabled={value.trim() !== currentRow.username}
|
||||
title={
|
||||
<span className='text-destructive'>
|
||||
<AlertTriangle
|
||||
className='me-1 inline-block stroke-destructive'
|
||||
size={18}
|
||||
/>{' '}
|
||||
Delete User
|
||||
</span>
|
||||
}
|
||||
desc={
|
||||
<div className='space-y-4'>
|
||||
<p className='mb-2'>
|
||||
Are you sure you want to delete{' '}
|
||||
<span className='font-bold'>{currentRow.username}</span>?
|
||||
<br />
|
||||
This action will permanently remove the user with the role of{' '}
|
||||
<span className='font-bold'>
|
||||
{currentRow.role.toUpperCase()}
|
||||
</span>{' '}
|
||||
from the system. This cannot be undone.
|
||||
</p>
|
||||
|
||||
<Label className='my-2'>
|
||||
Username:
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
placeholder='Enter username to confirm deletion.'
|
||||
/>
|
||||
</Label>
|
||||
|
||||
<Alert variant='destructive'>
|
||||
<AlertTitle>Warning!</AlertTitle>
|
||||
<AlertDescription>
|
||||
Please be careful, this operation can not be rolled back.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
}
|
||||
confirmText='Delete'
|
||||
destructive
|
||||
/>
|
||||
)
|
||||
}
|
||||
51
shadcn-admin/src/features/users/components/users-dialogs.tsx
Normal file
51
shadcn-admin/src/features/users/components/users-dialogs.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { UsersActionDialog } from './users-action-dialog'
|
||||
import { UsersDeleteDialog } from './users-delete-dialog'
|
||||
import { UsersInviteDialog } from './users-invite-dialog'
|
||||
import { useUsers } from './users-provider'
|
||||
|
||||
export function UsersDialogs() {
|
||||
const { open, setOpen, currentRow, setCurrentRow } = useUsers()
|
||||
return (
|
||||
<>
|
||||
<UsersActionDialog
|
||||
key='user-add'
|
||||
open={open === 'add'}
|
||||
onOpenChange={() => setOpen('add')}
|
||||
/>
|
||||
|
||||
<UsersInviteDialog
|
||||
key='user-invite'
|
||||
open={open === 'invite'}
|
||||
onOpenChange={() => setOpen('invite')}
|
||||
/>
|
||||
|
||||
{currentRow && (
|
||||
<>
|
||||
<UsersActionDialog
|
||||
key={`user-edit-${currentRow.id}`}
|
||||
open={open === 'edit'}
|
||||
onOpenChange={() => {
|
||||
setOpen('edit')
|
||||
setTimeout(() => {
|
||||
setCurrentRow(null)
|
||||
}, 500)
|
||||
}}
|
||||
currentRow={currentRow}
|
||||
/>
|
||||
|
||||
<UsersDeleteDialog
|
||||
key={`user-delete-${currentRow.id}`}
|
||||
open={open === 'delete'}
|
||||
onOpenChange={() => {
|
||||
setOpen('delete')
|
||||
setTimeout(() => {
|
||||
setCurrentRow(null)
|
||||
}, 500)
|
||||
}}
|
||||
currentRow={currentRow}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { z } from 'zod'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { MailPlus, Send } from 'lucide-react'
|
||||
import { showSubmittedData } from '@/lib/show-submitted-data'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { SelectDropdown } from '@/components/select-dropdown'
|
||||
import { roles } from '../data/data'
|
||||
|
||||
const formSchema = z.object({
|
||||
email: z.email({
|
||||
error: (iss) =>
|
||||
iss.input === '' ? 'Please enter an email to invite.' : undefined,
|
||||
}),
|
||||
role: z.string().min(1, 'Role is required.'),
|
||||
desc: z.string().optional(),
|
||||
})
|
||||
|
||||
type UserInviteForm = z.infer<typeof formSchema>
|
||||
|
||||
type UserInviteDialogProps = {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function UsersInviteDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: UserInviteDialogProps) {
|
||||
const form = useForm<UserInviteForm>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: { email: '', role: '', desc: '' },
|
||||
})
|
||||
|
||||
const onSubmit = (values: UserInviteForm) => {
|
||||
form.reset()
|
||||
showSubmittedData(values)
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(state) => {
|
||||
form.reset()
|
||||
onOpenChange(state)
|
||||
}}
|
||||
>
|
||||
<DialogContent className='sm:max-w-md'>
|
||||
<DialogHeader className='text-start'>
|
||||
<DialogTitle className='flex items-center gap-2'>
|
||||
<MailPlus /> Invite User
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Invite new user to join your team by sending them an email
|
||||
invitation. Assign a role to define their access level.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Form {...form}>
|
||||
<form
|
||||
id='user-invite-form'
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className='space-y-4'
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='email'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type='email'
|
||||
placeholder='eg: john.doe@gmail.com'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='role'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Role</FormLabel>
|
||||
<SelectDropdown
|
||||
defaultValue={field.value}
|
||||
onValueChange={field.onChange}
|
||||
placeholder='Select a role'
|
||||
items={roles.map(({ label, value }) => ({
|
||||
label,
|
||||
value,
|
||||
}))}
|
||||
/>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='desc'
|
||||
render={({ field }) => (
|
||||
<FormItem className=''>
|
||||
<FormLabel>Description (optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
className='resize-none'
|
||||
placeholder='Add a personal note to your invitation (optional)'
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
<DialogFooter className='gap-y-2'>
|
||||
<DialogClose asChild>
|
||||
<Button variant='outline'>Cancel</Button>
|
||||
</DialogClose>
|
||||
<Button type='submit' form='user-invite-form'>
|
||||
Invite <Send />
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { type Table } from '@tanstack/react-table'
|
||||
import { AlertTriangle } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { sleep } from '@/lib/utils'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
|
||||
type UserMultiDeleteDialogProps<TData> = {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
table: Table<TData>
|
||||
}
|
||||
|
||||
const CONFIRM_WORD = 'DELETE'
|
||||
|
||||
export function UsersMultiDeleteDialog<TData>({
|
||||
open,
|
||||
onOpenChange,
|
||||
table,
|
||||
}: UserMultiDeleteDialogProps<TData>) {
|
||||
const [value, setValue] = useState('')
|
||||
|
||||
const selectedRows = table.getFilteredSelectedRowModel().rows
|
||||
|
||||
const handleDelete = () => {
|
||||
if (value.trim() !== CONFIRM_WORD) {
|
||||
toast.error(`Please type "${CONFIRM_WORD}" to confirm.`)
|
||||
return
|
||||
}
|
||||
|
||||
onOpenChange(false)
|
||||
|
||||
toast.promise(sleep(2000), {
|
||||
loading: 'Deleting users...',
|
||||
success: () => {
|
||||
setValue('')
|
||||
table.resetRowSelection()
|
||||
return `Deleted ${selectedRows.length} ${
|
||||
selectedRows.length > 1 ? 'users' : 'user'
|
||||
}`
|
||||
},
|
||||
error: 'Error',
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<ConfirmDialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
handleConfirm={handleDelete}
|
||||
disabled={value.trim() !== CONFIRM_WORD}
|
||||
title={
|
||||
<span className='text-destructive'>
|
||||
<AlertTriangle
|
||||
className='me-1 inline-block stroke-destructive'
|
||||
size={18}
|
||||
/>{' '}
|
||||
Delete {selectedRows.length}{' '}
|
||||
{selectedRows.length > 1 ? 'users' : 'user'}
|
||||
</span>
|
||||
}
|
||||
desc={
|
||||
<div className='space-y-4'>
|
||||
<p className='mb-2'>
|
||||
Are you sure you want to delete the selected users? <br />
|
||||
This action cannot be undone.
|
||||
</p>
|
||||
|
||||
<Label className='my-4 flex flex-col items-start gap-1.5'>
|
||||
<span className=''>Confirm by typing "{CONFIRM_WORD}":</span>
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
placeholder={`Type "${CONFIRM_WORD}" to confirm.`}
|
||||
/>
|
||||
</Label>
|
||||
|
||||
<Alert variant='destructive'>
|
||||
<AlertTitle>Warning!</AlertTitle>
|
||||
<AlertDescription>
|
||||
Please be careful, this operation can not be rolled back.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
}
|
||||
confirmText='Delete'
|
||||
destructive
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { MailPlus, UserPlus } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useUsers } from './users-provider'
|
||||
|
||||
export function UsersPrimaryButtons() {
|
||||
const { setOpen } = useUsers()
|
||||
return (
|
||||
<div className='flex gap-2'>
|
||||
<Button
|
||||
variant='outline'
|
||||
className='space-x-1'
|
||||
onClick={() => setOpen('invite')}
|
||||
>
|
||||
<span>Invite User</span> <MailPlus size={18} />
|
||||
</Button>
|
||||
<Button className='space-x-1' onClick={() => setOpen('add')}>
|
||||
<span>Add User</span> <UserPlus size={18} />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import React, { useState } from 'react'
|
||||
import useDialogState from '@/hooks/use-dialog-state'
|
||||
import { type User } from '../data/schema'
|
||||
|
||||
type UsersDialogType = 'invite' | 'add' | 'edit' | 'delete'
|
||||
|
||||
type UsersContextType = {
|
||||
open: UsersDialogType | null
|
||||
setOpen: (str: UsersDialogType | null) => void
|
||||
currentRow: User | null
|
||||
setCurrentRow: React.Dispatch<React.SetStateAction<User | null>>
|
||||
}
|
||||
|
||||
const UsersContext = React.createContext<UsersContextType | null>(null)
|
||||
|
||||
export function UsersProvider({ children }: { children: React.ReactNode }) {
|
||||
const [open, setOpen] = useDialogState<UsersDialogType>(null)
|
||||
const [currentRow, setCurrentRow] = useState<User | null>(null)
|
||||
|
||||
return (
|
||||
<UsersContext value={{ open, setOpen, currentRow, setCurrentRow }}>
|
||||
{children}
|
||||
</UsersContext>
|
||||
)
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export const useUsers = () => {
|
||||
const usersContext = React.useContext(UsersContext)
|
||||
|
||||
if (!usersContext) {
|
||||
throw new Error('useUsers has to be used within <UsersContext>')
|
||||
}
|
||||
|
||||
return usersContext
|
||||
}
|
||||
194
shadcn-admin/src/features/users/components/users-table.tsx
Normal file
194
shadcn-admin/src/features/users/components/users-table.tsx
Normal file
@@ -0,0 +1,194 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
type SortingState,
|
||||
type VisibilityState,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getFacetedRowModel,
|
||||
getFacetedUniqueValues,
|
||||
getFilteredRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
} from '@tanstack/react-table'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { type NavigateFn, useTableUrlState } from '@/hooks/use-table-url-state'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { DataTablePagination, DataTableToolbar } from '@/components/data-table'
|
||||
import { roles } from '../data/data'
|
||||
import { type User } from '../data/schema'
|
||||
import { DataTableBulkActions } from './data-table-bulk-actions'
|
||||
import { usersColumns as columns } from './users-columns'
|
||||
|
||||
type DataTableProps = {
|
||||
data: User[]
|
||||
search: Record<string, unknown>
|
||||
navigate: NavigateFn
|
||||
}
|
||||
|
||||
export function UsersTable({ data, search, navigate }: DataTableProps) {
|
||||
// Local UI-only states
|
||||
const [rowSelection, setRowSelection] = useState({})
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
|
||||
const [sorting, setSorting] = useState<SortingState>([])
|
||||
|
||||
// Local state management for table (uncomment to use local-only state, not synced with URL)
|
||||
// const [columnFilters, onColumnFiltersChange] = useState<ColumnFiltersState>([])
|
||||
// const [pagination, onPaginationChange] = useState<PaginationState>({ pageIndex: 0, pageSize: 10 })
|
||||
|
||||
// Synced with URL states (keys/defaults mirror users route search schema)
|
||||
const {
|
||||
columnFilters,
|
||||
onColumnFiltersChange,
|
||||
pagination,
|
||||
onPaginationChange,
|
||||
ensurePageInRange,
|
||||
} = useTableUrlState({
|
||||
search,
|
||||
navigate,
|
||||
pagination: { defaultPage: 1, defaultPageSize: 10 },
|
||||
globalFilter: { enabled: false },
|
||||
columnFilters: [
|
||||
// username per-column text filter
|
||||
{ columnId: 'username', searchKey: 'username', type: 'string' },
|
||||
{ columnId: 'status', searchKey: 'status', type: 'array' },
|
||||
{ columnId: 'role', searchKey: 'role', type: 'array' },
|
||||
],
|
||||
})
|
||||
|
||||
// eslint-disable-next-line react-hooks/incompatible-library
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
state: {
|
||||
sorting,
|
||||
pagination,
|
||||
rowSelection,
|
||||
columnFilters,
|
||||
columnVisibility,
|
||||
},
|
||||
enableRowSelection: true,
|
||||
onPaginationChange,
|
||||
onColumnFiltersChange,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
onSortingChange: setSorting,
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFacetedRowModel: getFacetedRowModel(),
|
||||
getFacetedUniqueValues: getFacetedUniqueValues(),
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
ensurePageInRange(table.getPageCount())
|
||||
}, [table, ensurePageInRange])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'max-sm:has-[div[role="toolbar"]]:mb-16', // Add margin bottom to the table on mobile when the toolbar is visible
|
||||
'flex flex-1 flex-col gap-4'
|
||||
)}
|
||||
>
|
||||
<DataTableToolbar
|
||||
table={table}
|
||||
searchPlaceholder='Filter users...'
|
||||
searchKey='username'
|
||||
filters={[
|
||||
{
|
||||
columnId: 'status',
|
||||
title: 'Status',
|
||||
options: [
|
||||
{ label: 'Active', value: 'active' },
|
||||
{ label: 'Inactive', value: 'inactive' },
|
||||
{ label: 'Invited', value: 'invited' },
|
||||
{ label: 'Suspended', value: 'suspended' },
|
||||
],
|
||||
},
|
||||
{
|
||||
columnId: 'role',
|
||||
title: 'Role',
|
||||
options: roles.map((role) => ({ ...role })),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<div className='overflow-hidden rounded-md border'>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id} className='group/row'>
|
||||
{headerGroup.headers.map((header) => {
|
||||
return (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
colSpan={header.colSpan}
|
||||
className={cn(
|
||||
'bg-background group-hover/row:bg-muted group-data-[state=selected]/row:bg-muted',
|
||||
header.column.columnDef.meta?.className,
|
||||
header.column.columnDef.meta?.thClassName
|
||||
)}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</TableHead>
|
||||
)
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && 'selected'}
|
||||
className='group/row'
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
className={cn(
|
||||
'bg-background group-hover/row:bg-muted group-data-[state=selected]/row:bg-muted',
|
||||
cell.column.columnDef.meta?.className,
|
||||
cell.column.columnDef.meta?.tdClassName
|
||||
)}
|
||||
>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={columns.length}
|
||||
className='h-24 text-center'
|
||||
>
|
||||
No results.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<DataTablePagination table={table} className='mt-auto' />
|
||||
<DataTableBulkActions table={table} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
35
shadcn-admin/src/features/users/data/data.ts
Normal file
35
shadcn-admin/src/features/users/data/data.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { Shield, UserCheck, Users, CreditCard } from 'lucide-react'
|
||||
import { type UserStatus } from './schema'
|
||||
|
||||
export const callTypes = new Map<UserStatus, string>([
|
||||
['active', 'bg-teal-100/30 text-teal-900 dark:text-teal-200 border-teal-200'],
|
||||
['inactive', 'bg-neutral-300/40 border-neutral-300'],
|
||||
['invited', 'bg-sky-200/40 text-sky-900 dark:text-sky-100 border-sky-300'],
|
||||
[
|
||||
'suspended',
|
||||
'bg-destructive/10 dark:bg-destructive/50 text-destructive dark:text-primary border-destructive/10',
|
||||
],
|
||||
])
|
||||
|
||||
export const roles = [
|
||||
{
|
||||
label: 'Superadmin',
|
||||
value: 'superadmin',
|
||||
icon: Shield,
|
||||
},
|
||||
{
|
||||
label: 'Admin',
|
||||
value: 'admin',
|
||||
icon: UserCheck,
|
||||
},
|
||||
{
|
||||
label: 'Manager',
|
||||
value: 'manager',
|
||||
icon: Users,
|
||||
},
|
||||
{
|
||||
label: 'Cashier',
|
||||
value: 'cashier',
|
||||
icon: CreditCard,
|
||||
},
|
||||
] as const
|
||||
32
shadcn-admin/src/features/users/data/schema.ts
Normal file
32
shadcn-admin/src/features/users/data/schema.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
const userStatusSchema = z.union([
|
||||
z.literal('active'),
|
||||
z.literal('inactive'),
|
||||
z.literal('invited'),
|
||||
z.literal('suspended'),
|
||||
])
|
||||
export type UserStatus = z.infer<typeof userStatusSchema>
|
||||
|
||||
const userRoleSchema = z.union([
|
||||
z.literal('superadmin'),
|
||||
z.literal('admin'),
|
||||
z.literal('cashier'),
|
||||
z.literal('manager'),
|
||||
])
|
||||
|
||||
const userSchema = z.object({
|
||||
id: z.string(),
|
||||
firstName: z.string(),
|
||||
lastName: z.string(),
|
||||
username: z.string(),
|
||||
email: z.string(),
|
||||
phoneNumber: z.string(),
|
||||
status: userStatusSchema,
|
||||
role: userRoleSchema,
|
||||
createdAt: z.coerce.date(),
|
||||
updatedAt: z.coerce.date(),
|
||||
})
|
||||
export type User = z.infer<typeof userSchema>
|
||||
|
||||
export const userListSchema = z.array(userSchema)
|
||||
33
shadcn-admin/src/features/users/data/users.ts
Normal file
33
shadcn-admin/src/features/users/data/users.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { faker } from '@faker-js/faker'
|
||||
|
||||
// Set a fixed seed for consistent data generation
|
||||
faker.seed(67890)
|
||||
|
||||
export const users = Array.from({ length: 500 }, () => {
|
||||
const firstName = faker.person.firstName()
|
||||
const lastName = faker.person.lastName()
|
||||
return {
|
||||
id: faker.string.uuid(),
|
||||
firstName,
|
||||
lastName,
|
||||
username: faker.internet
|
||||
.username({ firstName, lastName })
|
||||
.toLocaleLowerCase(),
|
||||
email: faker.internet.email({ firstName }).toLocaleLowerCase(),
|
||||
phoneNumber: faker.phone.number({ style: 'international' }),
|
||||
status: faker.helpers.arrayElement([
|
||||
'active',
|
||||
'inactive',
|
||||
'invited',
|
||||
'suspended',
|
||||
]),
|
||||
role: faker.helpers.arrayElement([
|
||||
'superadmin',
|
||||
'admin',
|
||||
'cashier',
|
||||
'manager',
|
||||
]),
|
||||
createdAt: faker.date.past(),
|
||||
updatedAt: faker.date.recent(),
|
||||
}
|
||||
})
|
||||
47
shadcn-admin/src/features/users/index.tsx
Normal file
47
shadcn-admin/src/features/users/index.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { getRouteApi } from '@tanstack/react-router'
|
||||
import { ConfigDrawer } from '@/components/config-drawer'
|
||||
import { Header } from '@/components/layout/header'
|
||||
import { Main } from '@/components/layout/main'
|
||||
import { ProfileDropdown } from '@/components/profile-dropdown'
|
||||
import { Search } from '@/components/search'
|
||||
import { ThemeSwitch } from '@/components/theme-switch'
|
||||
import { UsersDialogs } from './components/users-dialogs'
|
||||
import { UsersPrimaryButtons } from './components/users-primary-buttons'
|
||||
import { UsersProvider } from './components/users-provider'
|
||||
import { UsersTable } from './components/users-table'
|
||||
import { users } from './data/users'
|
||||
|
||||
const route = getRouteApi('/_authenticated/users/')
|
||||
|
||||
export function Users() {
|
||||
const search = route.useSearch()
|
||||
const navigate = route.useNavigate()
|
||||
|
||||
return (
|
||||
<UsersProvider>
|
||||
<Header fixed>
|
||||
<Search />
|
||||
<div className='ms-auto flex items-center space-x-4'>
|
||||
<ThemeSwitch />
|
||||
<ConfigDrawer />
|
||||
<ProfileDropdown />
|
||||
</div>
|
||||
</Header>
|
||||
|
||||
<Main className='flex flex-1 flex-col gap-4 sm:gap-6'>
|
||||
<div className='flex flex-wrap items-end justify-between gap-2'>
|
||||
<div>
|
||||
<h2 className='text-2xl font-bold tracking-tight'>User List</h2>
|
||||
<p className='text-muted-foreground'>
|
||||
Manage your users and their roles here.
|
||||
</p>
|
||||
</div>
|
||||
<UsersPrimaryButtons />
|
||||
</div>
|
||||
<UsersTable data={users} search={search} navigate={navigate} />
|
||||
</Main>
|
||||
|
||||
<UsersDialogs />
|
||||
</UsersProvider>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user