Răsfoiți Sursa

feat: implement users CRUD

Resolves #9
satnaing 1 an în urmă
părinte
comite
15a3a94e48

+ 2 - 0
package.json

@@ -13,6 +13,7 @@
   },
   "dependencies": {
     "@hookform/resolvers": "^3.9.1",
+    "@radix-ui/react-alert-dialog": "^1.1.2",
     "@radix-ui/react-avatar": "^1.1.1",
     "@radix-ui/react-checkbox": "^1.1.2",
     "@radix-ui/react-collapsible": "^1.1.1",
@@ -55,6 +56,7 @@
   },
   "devDependencies": {
     "@eslint/js": "^9.13.0",
+    "@faker-js/faker": "^9.3.0",
     "@tanstack/eslint-plugin-query": "^5.60.1",
     "@tanstack/react-query-devtools": "^5.59.20",
     "@tanstack/router-devtools": "^1.81.4",

+ 39 - 0
pnpm-lock.yaml

@@ -11,6 +11,9 @@ importers:
       '@hookform/resolvers':
         specifier: ^3.9.1
         version: 3.9.1(react-hook-form@7.53.2(react@18.3.1))
+      '@radix-ui/react-alert-dialog':
+        specifier: ^1.1.2
+        version: 1.1.2(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
       '@radix-ui/react-avatar':
         specifier: ^1.1.1
         version: 1.1.1(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -132,6 +135,9 @@ importers:
       '@eslint/js':
         specifier: ^9.13.0
         version: 9.14.0
+      '@faker-js/faker':
+        specifier: ^9.3.0
+        version: 9.3.0
       '@tanstack/eslint-plugin-query':
         specifier: ^5.60.1
         version: 5.60.1(eslint@9.14.0(jiti@1.21.6))(typescript@5.6.3)
@@ -636,6 +642,10 @@ packages:
     resolution: {integrity: sha512-CXtq5nR4Su+2I47WPOlWud98Y5Lv8Kyxp2ukhgFx/eW6Blm18VXJO5WuQylPugRo8nbluoi6GvvxBLqHcvqUUw==}
     engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
 
+  '@faker-js/faker@9.3.0':
+    resolution: {integrity: sha512-r0tJ3ZOkMd9xsu3VRfqlFR6cz0V/jFYRswAIpC+m/DIfAUXq7g8N7wTAlhSANySXYGKzGryfDXwtwsY8TxEIDw==}
+    engines: {node: '>=18.0.0', npm: '>=9.0.0'}
+
   '@floating-ui/core@1.6.8':
     resolution: {integrity: sha512-7XJ9cPU+yI2QeLS+FCSlqNFZJq8arvswefkZrYI1yQBbftw6FyrZOxYSh+9S7z7TpeWlRt9zJ5IhM1WIL334jA==}
 
@@ -738,6 +748,19 @@ packages:
   '@radix-ui/primitive@1.1.0':
     resolution: {integrity: sha512-4Z8dn6Upk0qk4P74xBhZ6Hd/w0mPEzOOLxy4xiPXOXqjF7jZS0VAKk7/x/H6FyY2zCkYJqePf1G5KmkmNJ4RBA==}
 
+  '@radix-ui/react-alert-dialog@1.1.2':
+    resolution: {integrity: sha512-eGSlLzPhKO+TErxkiGcCZGuvbVMnLA1MTnyBksGOeGRGkxHiiJUujsjmNTdWTm4iHVSRaUao9/4Ur671auMghQ==}
+    peerDependencies:
+      '@types/react': '*'
+      '@types/react-dom': '*'
+      react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+      react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+      '@types/react-dom':
+        optional: true
+
   '@radix-ui/react-arrow@1.1.0':
     resolution: {integrity: sha512-FmlW1rCg7hBpEBwFbjHwCW6AmWLQM6g/v0Sn8XbP9NvmSZ2San1FpQeyPtufzOMSIx7Y4dzjlHoifhp+7NkZhw==}
     peerDependencies:
@@ -3306,6 +3329,8 @@ snapshots:
     dependencies:
       levn: 0.4.1
 
+  '@faker-js/faker@9.3.0': {}
+
   '@floating-ui/core@1.6.8':
     dependencies:
       '@floating-ui/utils': 0.2.8
@@ -3414,6 +3439,20 @@ snapshots:
 
   '@radix-ui/primitive@1.1.0': {}
 
+  '@radix-ui/react-alert-dialog@1.1.2(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+    dependencies:
+      '@radix-ui/primitive': 1.1.0
+      '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.3.12)(react@18.3.1)
+      '@radix-ui/react-context': 1.1.1(@types/react@18.3.12)(react@18.3.1)
+      '@radix-ui/react-dialog': 1.1.2(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+      '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+      '@radix-ui/react-slot': 1.1.0(@types/react@18.3.12)(react@18.3.1)
+      react: 18.3.1
+      react-dom: 18.3.1(react@18.3.1)
+    optionalDependencies:
+      '@types/react': 18.3.12
+      '@types/react-dom': 18.3.1
+
   '@radix-ui/react-arrow@1.1.0(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
     dependencies:
       '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)

+ 67 - 0
src/components/confirm-dialog.tsx

@@ -0,0 +1,67 @@
+import { cn } from '@/lib/utils'
+import {
+  AlertDialog,
+  AlertDialogCancel,
+  AlertDialogContent,
+  AlertDialogDescription,
+  AlertDialogFooter,
+  AlertDialogHeader,
+  AlertDialogTitle,
+} from '@/components/ui/alert-dialog'
+import { Button } from '@/components/ui/button'
+
+interface ConfirmDialogProps {
+  open: boolean
+  onOpenChange: (open: boolean) => void
+  title: React.ReactNode
+  disabled?: boolean
+  desc: JSX.Element | string
+  cancelBtnText?: string
+  confirmText?: React.ReactNode
+  destructive?: boolean
+  handleConfirm: () => void
+  isLoading?: boolean
+  className?: string
+  children?: React.ReactNode
+}
+
+export function ConfirmDialog(props: ConfirmDialogProps) {
+  const {
+    title,
+    desc,
+    children,
+    className,
+    confirmText,
+    cancelBtnText,
+    destructive,
+    isLoading,
+    disabled = false,
+    handleConfirm,
+    ...actions
+  } = props
+  return (
+    <AlertDialog {...actions}>
+      <AlertDialogContent className={cn(className && className)}>
+        <AlertDialogHeader>
+          <AlertDialogTitle>{title}</AlertDialogTitle>
+          <AlertDialogDescription asChild>
+            <div>{desc}</div>
+          </AlertDialogDescription>
+        </AlertDialogHeader>
+        {children}
+        <AlertDialogFooter>
+          <AlertDialogCancel disabled={isLoading}>
+            {cancelBtnText ?? 'Cancel'}
+          </AlertDialogCancel>
+          <Button
+            variant={destructive ? 'destructive' : 'default'}
+            onClick={handleConfirm}
+            disabled={disabled || isLoading}
+          >
+            {confirmText ?? 'Continue'}
+          </Button>
+        </AlertDialogFooter>
+      </AlertDialogContent>
+    </AlertDialog>
+  )
+}

+ 1 - 1
src/components/layout/data/sidebar-data.ts

@@ -72,7 +72,7 @@ export const sidebarData: SidebarData = {
         },
         {
           title: 'Users',
-          url: '/',
+          url: '/users',
           icon: IconUsers,
         },
       ],

+ 85 - 0
src/components/long-text.tsx

@@ -0,0 +1,85 @@
+import { useEffect, useRef, useState } from 'react'
+import { cn } from '@/lib/utils'
+import {
+  Popover,
+  PopoverContent,
+  PopoverTrigger,
+} from '@/components/ui/popover'
+import {
+  Tooltip,
+  TooltipContent,
+  TooltipProvider,
+  TooltipTrigger,
+} from '@/components/ui/tooltip'
+
+interface Props {
+  children: React.ReactNode
+  className?: string
+  contentClassName?: string
+}
+
+export default function LongText({
+  children,
+  className = '',
+  contentClassName = '',
+}: Props) {
+  const ref = useRef<HTMLDivElement>(null)
+  const [isOverflown, setIsOverflown] = useState(false)
+
+  useEffect(() => {
+    if (checkOverflow(ref.current)) {
+      setIsOverflown(true)
+      return
+    }
+
+    setIsOverflown(false)
+  }, [])
+
+  if (!isOverflown)
+    return (
+      <div ref={ref} className={cn('truncate', className)}>
+        {children}
+      </div>
+    )
+
+  return (
+    <>
+      <div className='hidden sm:block'>
+        <TooltipProvider delayDuration={0}>
+          <Tooltip>
+            <TooltipTrigger asChild>
+              <div ref={ref} className={cn('truncate', className)}>
+                {children}
+              </div>
+            </TooltipTrigger>
+            <TooltipContent>
+              <p className={contentClassName}>{children}</p>
+            </TooltipContent>
+          </Tooltip>
+        </TooltipProvider>
+      </div>
+      <div className='sm:hidden'>
+        <Popover>
+          <PopoverTrigger asChild>
+            <div ref={ref} className={cn('truncate', className)}>
+              {children}
+            </div>
+          </PopoverTrigger>
+          <PopoverContent className={cn('w-fit', contentClassName)}>
+            <p>{children}</p>
+          </PopoverContent>
+        </Popover>
+      </div>
+    </>
+  )
+}
+
+const checkOverflow = (textContainer: HTMLDivElement | null) => {
+  if (textContainer) {
+    return (
+      textContainer.offsetHeight < textContainer.scrollHeight ||
+      textContainer.offsetWidth < textContainer.scrollWidth
+    )
+  }
+  return false
+}

+ 5 - 6
src/components/password-input.tsx

@@ -9,23 +9,22 @@ type PasswordInputProps = Omit<
 >
 
 const PasswordInput = React.forwardRef<HTMLInputElement, PasswordInputProps>(
-  ({ className, ...props }, ref) => {
+  ({ className, disabled, ...props }, ref) => {
     const [showPassword, setShowPassword] = React.useState(false)
     return (
-      <div className='relative rounded-md'>
+      <div className={cn('relative rounded-md', className)}>
         <input
           type={showPassword ? 'text' : 'password'}
-          className={cn(
-            'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
-            className
-          )}
+          className='flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50'
           ref={ref}
+          disabled={disabled}
           {...props}
         />
         <Button
           type='button'
           size='icon'
           variant='ghost'
+          disabled={disabled}
           className='absolute right-1 top-1/2 h-6 w-6 -translate-y-1/2 rounded-md text-muted-foreground'
           onClick={() => setShowPassword((prev) => !prev)}
         >

+ 62 - 0
src/components/select-dropdown.tsx

@@ -0,0 +1,62 @@
+import { IconLoader } from '@tabler/icons-react'
+import { cn } from '@/lib/utils'
+import { FormControl } from '@/components/ui/form'
+import {
+  Select,
+  SelectContent,
+  SelectItem,
+  SelectTrigger,
+  SelectValue,
+} from '@/components/ui/select'
+
+interface SelectDropdownProps {
+  onValueChange?: (value: string) => void
+  defaultValue: string | undefined
+  placeholder?: string
+  isPending?: boolean
+  items: { label: string; value: string }[] | undefined
+  disabled?: boolean
+  className?: string
+  isControlled?: boolean
+}
+
+export function SelectDropdown({
+  defaultValue,
+  onValueChange,
+  isPending,
+  items,
+  placeholder,
+  disabled,
+  className = '',
+  isControlled = false,
+}: SelectDropdownProps) {
+  const defaultState = isControlled
+    ? { value: defaultValue, onValueChange }
+    : { defaultValue, onValueChange }
+  return (
+    <Select {...defaultState}>
+      <FormControl>
+        <SelectTrigger disabled={disabled} className={cn(className)}>
+          <SelectValue placeholder={placeholder ?? 'Select'} />
+        </SelectTrigger>
+      </FormControl>
+      <SelectContent>
+        {isPending ? (
+          <SelectItem disabled value='loading' className='h-14'>
+            <div className='flex items-center justify-center gap-2'>
+              <IconLoader className='h-5 w-5 animate-spin' />
+              {'  '}
+              Loading...
+            </div>
+          </SelectItem>
+        ) : (
+          items?.map(({ label, value }) => (
+            <SelectItem key={value} value={value}>
+              {label}
+            </SelectItem>
+          ))
+        )}
+      </SelectContent>
+    </Select>
+  )
+}

+ 138 - 0
src/components/ui/alert-dialog.tsx

@@ -0,0 +1,138 @@
+import * as React from 'react'
+import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog'
+import { cn } from '@/lib/utils'
+import { buttonVariants } from '@/components/ui/button'
+
+const AlertDialog = AlertDialogPrimitive.Root
+
+const AlertDialogTrigger = AlertDialogPrimitive.Trigger
+
+const AlertDialogPortal = AlertDialogPrimitive.Portal
+
+const AlertDialogOverlay = React.forwardRef<
+  React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
+  React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
+>(({ className, ...props }, ref) => (
+  <AlertDialogPrimitive.Overlay
+    className={cn(
+      'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
+      className
+    )}
+    {...props}
+    ref={ref}
+  />
+))
+AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
+
+const AlertDialogContent = React.forwardRef<
+  React.ElementRef<typeof AlertDialogPrimitive.Content>,
+  React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
+>(({ className, ...props }, ref) => (
+  <AlertDialogPortal>
+    <AlertDialogOverlay />
+    <AlertDialogPrimitive.Content
+      ref={ref}
+      className={cn(
+        'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
+        className
+      )}
+      {...props}
+    />
+  </AlertDialogPortal>
+))
+AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
+
+const AlertDialogHeader = ({
+  className,
+  ...props
+}: React.HTMLAttributes<HTMLDivElement>) => (
+  <div
+    className={cn(
+      'flex flex-col space-y-2 text-center sm:text-left',
+      className
+    )}
+    {...props}
+  />
+)
+AlertDialogHeader.displayName = 'AlertDialogHeader'
+
+const AlertDialogFooter = ({
+  className,
+  ...props
+}: React.HTMLAttributes<HTMLDivElement>) => (
+  <div
+    className={cn(
+      'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2',
+      className
+    )}
+    {...props}
+  />
+)
+AlertDialogFooter.displayName = 'AlertDialogFooter'
+
+const AlertDialogTitle = React.forwardRef<
+  React.ElementRef<typeof AlertDialogPrimitive.Title>,
+  React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
+>(({ className, ...props }, ref) => (
+  <AlertDialogPrimitive.Title
+    ref={ref}
+    className={cn('text-lg font-semibold', className)}
+    {...props}
+  />
+))
+AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
+
+const AlertDialogDescription = React.forwardRef<
+  React.ElementRef<typeof AlertDialogPrimitive.Description>,
+  React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
+>(({ className, ...props }, ref) => (
+  <AlertDialogPrimitive.Description
+    ref={ref}
+    className={cn('text-sm text-muted-foreground', className)}
+    {...props}
+  />
+))
+AlertDialogDescription.displayName =
+  AlertDialogPrimitive.Description.displayName
+
+const AlertDialogAction = React.forwardRef<
+  React.ElementRef<typeof AlertDialogPrimitive.Action>,
+  React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
+>(({ className, ...props }, ref) => (
+  <AlertDialogPrimitive.Action
+    ref={ref}
+    className={cn(buttonVariants(), className)}
+    {...props}
+  />
+))
+AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
+
+const AlertDialogCancel = React.forwardRef<
+  React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
+  React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
+>(({ className, ...props }, ref) => (
+  <AlertDialogPrimitive.Cancel
+    ref={ref}
+    className={cn(
+      buttonVariants({ variant: 'outline' }),
+      'mt-2 sm:mt-0',
+      className
+    )}
+    {...props}
+  />
+))
+AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
+
+export {
+  AlertDialog,
+  AlertDialogPortal,
+  AlertDialogOverlay,
+  AlertDialogTrigger,
+  AlertDialogContent,
+  AlertDialogHeader,
+  AlertDialogFooter,
+  AlertDialogTitle,
+  AlertDialogDescription,
+  AlertDialogAction,
+  AlertDialogCancel,
+}

+ 58 - 0
src/components/ui/alert.tsx

@@ -0,0 +1,58 @@
+import * as React from 'react'
+import { cva, type VariantProps } from 'class-variance-authority'
+import { cn } from '@/lib/utils'
+
+const alertVariants = cva(
+  'relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7',
+  {
+    variants: {
+      variant: {
+        default: 'bg-background text-foreground',
+        destructive:
+          'border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive',
+      },
+    },
+    defaultVariants: {
+      variant: 'default',
+    },
+  }
+)
+
+const Alert = React.forwardRef<
+  HTMLDivElement,
+  React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
+>(({ className, variant, ...props }, ref) => (
+  <div
+    ref={ref}
+    role='alert'
+    className={cn(alertVariants({ variant }), className)}
+    {...props}
+  />
+))
+Alert.displayName = 'Alert'
+
+const AlertTitle = React.forwardRef<
+  HTMLParagraphElement,
+  React.HTMLAttributes<HTMLHeadingElement>
+>(({ className, ...props }, ref) => (
+  <h5
+    ref={ref}
+    className={cn('mb-1 font-medium leading-none tracking-tight', className)}
+    {...props}
+  />
+))
+AlertTitle.displayName = 'AlertTitle'
+
+const AlertDescription = React.forwardRef<
+  HTMLParagraphElement,
+  React.HTMLAttributes<HTMLParagraphElement>
+>(({ className, ...props }, ref) => (
+  <div
+    ref={ref}
+    className={cn('text-sm [&_p]:leading-relaxed', className)}
+    {...props}
+  />
+))
+AlertDescription.displayName = 'AlertDescription'
+
+export { Alert, AlertTitle, AlertDescription }

+ 70 - 0
src/features/users/components/data-table-column-header.tsx

@@ -0,0 +1,70 @@
+import {
+  ArrowDownIcon,
+  ArrowUpIcon,
+  CaretSortIcon,
+  EyeNoneIcon,
+} from '@radix-ui/react-icons'
+import { Column } from '@tanstack/react-table'
+import { cn } from '@/lib/utils'
+import { Button } from '@/components/ui/button'
+import {
+  DropdownMenu,
+  DropdownMenuContent,
+  DropdownMenuItem,
+  DropdownMenuSeparator,
+  DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu'
+
+interface DataTableColumnHeaderProps<TData, TValue>
+  extends React.HTMLAttributes<HTMLDivElement> {
+  column: Column<TData, TValue>
+  title: string
+}
+
+export function DataTableColumnHeader<TData, TValue>({
+  column,
+  title,
+  className,
+}: DataTableColumnHeaderProps<TData, TValue>) {
+  if (!column.getCanSort()) {
+    return <div className={cn(className)}>{title}</div>
+  }
+
+  return (
+    <div className={cn('flex items-center space-x-2', className)}>
+      <DropdownMenu>
+        <DropdownMenuTrigger asChild>
+          <Button
+            variant='ghost'
+            size='sm'
+            className='-ml-3 h-8 data-[state=open]:bg-accent'
+          >
+            <span>{title}</span>
+            {column.getIsSorted() === 'desc' ? (
+              <ArrowDownIcon className='ml-2 h-4 w-4' />
+            ) : column.getIsSorted() === 'asc' ? (
+              <ArrowUpIcon className='ml-2 h-4 w-4' />
+            ) : (
+              <CaretSortIcon className='ml-2 h-4 w-4' />
+            )}
+          </Button>
+        </DropdownMenuTrigger>
+        <DropdownMenuContent align='start'>
+          <DropdownMenuItem onClick={() => column.toggleSorting(false)}>
+            <ArrowUpIcon className='mr-2 h-3.5 w-3.5 text-muted-foreground/70' />
+            Asc
+          </DropdownMenuItem>
+          <DropdownMenuItem onClick={() => column.toggleSorting(true)}>
+            <ArrowDownIcon className='mr-2 h-3.5 w-3.5 text-muted-foreground/70' />
+            Desc
+          </DropdownMenuItem>
+          <DropdownMenuSeparator />
+          <DropdownMenuItem onClick={() => column.toggleVisibility(false)}>
+            <EyeNoneIcon className='mr-2 h-3.5 w-3.5 text-muted-foreground/70' />
+            Hide
+          </DropdownMenuItem>
+        </DropdownMenuContent>
+      </DropdownMenu>
+    </div>
+  )
+}

+ 145 - 0
src/features/users/components/data-table-faceted-filter.tsx

@@ -0,0 +1,145 @@
+import * as React from 'react'
+import { CheckIcon, PlusCircledIcon } from '@radix-ui/react-icons'
+import { Column } from '@tanstack/react-table'
+import { cn } from '@/lib/utils'
+import { Badge } from '@/components/ui/badge'
+import { Button } from '@/components/ui/button'
+import {
+  Command,
+  CommandEmpty,
+  CommandGroup,
+  CommandInput,
+  CommandItem,
+  CommandList,
+  CommandSeparator,
+} from '@/components/ui/command'
+import {
+  Popover,
+  PopoverContent,
+  PopoverTrigger,
+} from '@/components/ui/popover'
+import { Separator } from '@/components/ui/separator'
+
+interface DataTableFacetedFilterProps<TData, TValue> {
+  column?: Column<TData, TValue>
+  title?: string
+  options: {
+    label: string
+    value: string
+    icon?: React.ComponentType<{ className?: string }>
+  }[]
+}
+
+export function DataTableFacetedFilter<TData, TValue>({
+  column,
+  title,
+  options,
+}: DataTableFacetedFilterProps<TData, TValue>) {
+  const facets = column?.getFacetedUniqueValues()
+  const selectedValues = new Set(column?.getFilterValue() as string[])
+  return (
+    <Popover>
+      <PopoverTrigger asChild>
+        <Button variant='outline' size='sm' className='h-8 border-dashed'>
+          <PlusCircledIcon className='h-4 w-4' />
+          {title}
+          {selectedValues?.size > 0 && (
+            <>
+              <Separator orientation='vertical' className='mx-2 h-4' />
+              <Badge
+                variant='secondary'
+                className='rounded-sm px-1 font-normal lg:hidden'
+              >
+                {selectedValues.size}
+              </Badge>
+              <div className='hidden space-x-1 lg:flex'>
+                {selectedValues.size > 2 ? (
+                  <Badge
+                    variant='secondary'
+                    className='rounded-sm px-1 font-normal'
+                  >
+                    {selectedValues.size} selected
+                  </Badge>
+                ) : (
+                  options
+                    .filter((option) => selectedValues.has(option.value))
+                    .map((option) => (
+                      <Badge
+                        variant='secondary'
+                        key={option.value}
+                        className='rounded-sm px-1 font-normal'
+                      >
+                        {option.label}
+                      </Badge>
+                    ))
+                )}
+              </div>
+            </>
+          )}
+        </Button>
+      </PopoverTrigger>
+      <PopoverContent className='w-[200px] p-0' align='start'>
+        <Command>
+          <CommandInput placeholder={title} />
+          <CommandList>
+            <CommandEmpty>No results found.</CommandEmpty>
+            <CommandGroup>
+              {options.map((option) => {
+                const isSelected = selectedValues.has(option.value)
+                return (
+                  <CommandItem
+                    key={option.value}
+                    onSelect={() => {
+                      if (isSelected) {
+                        selectedValues.delete(option.value)
+                      } else {
+                        selectedValues.add(option.value)
+                      }
+                      const filterValues = Array.from(selectedValues)
+                      column?.setFilterValue(
+                        filterValues.length ? filterValues : undefined
+                      )
+                    }}
+                  >
+                    <div
+                      className={cn(
+                        'flex h-4 w-4 items-center justify-center rounded-sm border border-primary',
+                        isSelected
+                          ? 'bg-primary text-primary-foreground'
+                          : 'opacity-50 [&_svg]:invisible'
+                      )}
+                    >
+                      <CheckIcon className={cn('h-4 w-4')} />
+                    </div>
+                    {option.icon && (
+                      <option.icon className='h-4 w-4 text-muted-foreground' />
+                    )}
+                    <span>{option.label}</span>
+                    {facets?.get(option.value) && (
+                      <span className='ml-auto flex h-4 w-4 items-center justify-center font-mono text-xs'>
+                        {facets.get(option.value)}
+                      </span>
+                    )}
+                  </CommandItem>
+                )
+              })}
+            </CommandGroup>
+            {selectedValues.size > 0 && (
+              <>
+                <CommandSeparator />
+                <CommandGroup>
+                  <CommandItem
+                    onSelect={() => column?.setFilterValue(undefined)}
+                    className='justify-center text-center'
+                  >
+                    Clear filters
+                  </CommandItem>
+                </CommandGroup>
+              </>
+            )}
+          </CommandList>
+        </Command>
+      </PopoverContent>
+    </Popover>
+  )
+}

+ 96 - 0
src/features/users/components/data-table-pagination.tsx

@@ -0,0 +1,96 @@
+import {
+  ChevronLeftIcon,
+  ChevronRightIcon,
+  DoubleArrowLeftIcon,
+  DoubleArrowRightIcon,
+} from '@radix-ui/react-icons'
+import { Table } from '@tanstack/react-table'
+import { Button } from '@/components/ui/button'
+import {
+  Select,
+  SelectContent,
+  SelectItem,
+  SelectTrigger,
+  SelectValue,
+} from '@/components/ui/select'
+
+interface DataTablePaginationProps<TData> {
+  table: Table<TData>
+}
+
+export function DataTablePagination<TData>({
+  table,
+}: DataTablePaginationProps<TData>) {
+  return (
+    <div className='flex items-center justify-between overflow-auto px-2'>
+      <div className='hidden flex-1 text-sm text-muted-foreground sm:block'>
+        {table.getFilteredSelectedRowModel().rows.length} of{' '}
+        {table.getFilteredRowModel().rows.length} row(s) selected.
+      </div>
+      <div className='flex items-center sm:space-x-6 lg:space-x-8'>
+        <div className='flex items-center space-x-2'>
+          <p className='hidden text-sm font-medium sm:block'>Rows per page</p>
+          <Select
+            value={`${table.getState().pagination.pageSize}`}
+            onValueChange={(value) => {
+              table.setPageSize(Number(value))
+            }}
+          >
+            <SelectTrigger className='h-8 w-[70px]'>
+              <SelectValue placeholder={table.getState().pagination.pageSize} />
+            </SelectTrigger>
+            <SelectContent side='top'>
+              {[10, 20, 30, 40, 50].map((pageSize) => (
+                <SelectItem key={pageSize} value={`${pageSize}`}>
+                  {pageSize}
+                </SelectItem>
+              ))}
+            </SelectContent>
+          </Select>
+        </div>
+        <div className='flex w-[100px] items-center justify-center text-sm font-medium'>
+          Page {table.getState().pagination.pageIndex + 1} of{' '}
+          {table.getPageCount()}
+        </div>
+        <div className='flex items-center space-x-2'>
+          <Button
+            variant='outline'
+            className='hidden h-8 w-8 p-0 lg:flex'
+            onClick={() => table.setPageIndex(0)}
+            disabled={!table.getCanPreviousPage()}
+          >
+            <span className='sr-only'>Go to first page</span>
+            <DoubleArrowLeftIcon className='h-4 w-4' />
+          </Button>
+          <Button
+            variant='outline'
+            className='h-8 w-8 p-0'
+            onClick={() => table.previousPage()}
+            disabled={!table.getCanPreviousPage()}
+          >
+            <span className='sr-only'>Go to previous page</span>
+            <ChevronLeftIcon className='h-4 w-4' />
+          </Button>
+          <Button
+            variant='outline'
+            className='h-8 w-8 p-0'
+            onClick={() => table.nextPage()}
+            disabled={!table.getCanNextPage()}
+          >
+            <span className='sr-only'>Go to next page</span>
+            <ChevronRightIcon className='h-4 w-4' />
+          </Button>
+          <Button
+            variant='outline'
+            className='hidden h-8 w-8 p-0 lg:flex'
+            onClick={() => table.setPageIndex(table.getPageCount() - 1)}
+            disabled={!table.getCanNextPage()}
+          >
+            <span className='sr-only'>Go to last page</span>
+            <DoubleArrowRightIcon className='h-4 w-4' />
+          </Button>
+        </div>
+      </div>
+    </div>
+  )
+}

+ 63 - 0
src/features/users/components/data-table-row-actions.tsx

@@ -0,0 +1,63 @@
+import { DotsHorizontalIcon } from '@radix-ui/react-icons'
+import { Row } from '@tanstack/react-table'
+import { IconEdit, IconTrash } from '@tabler/icons-react'
+import { Button } from '@/components/ui/button'
+import {
+  DropdownMenu,
+  DropdownMenuContent,
+  DropdownMenuItem,
+  DropdownMenuSeparator,
+  DropdownMenuShortcut,
+  DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu'
+import { useUsersContext } from '../context/users-context'
+import { User } from '../data/schema'
+
+interface DataTableRowActionsProps {
+  row: Row<User>
+}
+
+export function DataTableRowActions({ row }: DataTableRowActionsProps) {
+  const { setOpen, setCurrentRow } = useUsersContext()
+  return (
+    <>
+      <DropdownMenu>
+        <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>
+              <IconEdit size={16} />
+            </DropdownMenuShortcut>
+          </DropdownMenuItem>
+          <DropdownMenuSeparator />
+          <DropdownMenuItem
+            onClick={() => {
+              setCurrentRow(row.original)
+              setOpen('delete')
+            }}
+            className='!text-red-500'
+          >
+            Delete
+            <DropdownMenuShortcut>
+              <IconTrash size={16} />
+            </DropdownMenuShortcut>
+          </DropdownMenuItem>
+        </DropdownMenuContent>
+      </DropdownMenu>
+    </>
+  )
+}

+ 66 - 0
src/features/users/components/data-table-toolbar.tsx

@@ -0,0 +1,66 @@
+import { Cross2Icon } from '@radix-ui/react-icons'
+import { Table } from '@tanstack/react-table'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { userTypes } from '../data/data'
+import { DataTableFacetedFilter } from './data-table-faceted-filter'
+import { DataTableViewOptions } from './data-table-view-options'
+
+interface DataTableToolbarProps<TData> {
+  table: Table<TData>
+}
+
+export function DataTableToolbar<TData>({
+  table,
+}: DataTableToolbarProps<TData>) {
+  const isFiltered = table.getState().columnFilters.length > 0
+
+  return (
+    <div className='flex items-center justify-between'>
+      <div className='flex flex-1 flex-col-reverse items-start gap-y-2 sm:flex-row sm:items-center sm:space-x-2'>
+        <Input
+          placeholder='Filter tasks...'
+          value={
+            (table.getColumn('username')?.getFilterValue() as string) ?? ''
+          }
+          onChange={(event) =>
+            table.getColumn('username')?.setFilterValue(event.target.value)
+          }
+          className='h-8 w-[150px] lg:w-[250px]'
+        />
+        <div className='flex gap-x-2'>
+          {table.getColumn('status') && (
+            <DataTableFacetedFilter
+              column={table.getColumn('status')}
+              title='Status'
+              options={[
+                { label: 'Active', value: 'active' },
+                { label: 'Inactive', value: 'inactive' },
+                { label: 'Invited', value: 'invited' },
+                { label: 'Suspended', value: 'suspended' },
+              ]}
+            />
+          )}
+          {table.getColumn('role') && (
+            <DataTableFacetedFilter
+              column={table.getColumn('role')}
+              title='Role'
+              options={userTypes.map((t) => ({ ...t }))}
+            />
+          )}
+        </div>
+        {isFiltered && (
+          <Button
+            variant='ghost'
+            onClick={() => table.resetColumnFilters()}
+            className='h-8 px-2 lg:px-3'
+          >
+            Reset
+            <Cross2Icon className='ml-2 h-4 w-4' />
+          </Button>
+        )}
+      </div>
+      <DataTableViewOptions table={table} />
+    </div>
+  )
+}

+ 56 - 0
src/features/users/components/data-table-view-options.tsx

@@ -0,0 +1,56 @@
+import { DropdownMenuTrigger } from '@radix-ui/react-dropdown-menu'
+import { MixerHorizontalIcon } from '@radix-ui/react-icons'
+import { Table } from '@tanstack/react-table'
+import { Button } from '@/components/ui/button'
+import {
+  DropdownMenu,
+  DropdownMenuCheckboxItem,
+  DropdownMenuContent,
+  DropdownMenuLabel,
+  DropdownMenuSeparator,
+} from '@/components/ui/dropdown-menu'
+
+interface DataTableViewOptionsProps<TData> {
+  table: Table<TData>
+}
+
+export function DataTableViewOptions<TData>({
+  table,
+}: DataTableViewOptionsProps<TData>) {
+  return (
+    <DropdownMenu>
+      <DropdownMenuTrigger asChild>
+        <Button
+          variant='outline'
+          size='sm'
+          className='ml-auto hidden h-8 lg:flex'
+        >
+          <MixerHorizontalIcon className='mr-2 h-4 w-4' />
+          View
+        </Button>
+      </DropdownMenuTrigger>
+      <DropdownMenuContent align='end' className='w-[150px]'>
+        <DropdownMenuLabel>Toggle columns</DropdownMenuLabel>
+        <DropdownMenuSeparator />
+        {table
+          .getAllColumns()
+          .filter(
+            (column) =>
+              typeof column.accessorFn !== 'undefined' && column.getCanHide()
+          )
+          .map((column) => {
+            return (
+              <DropdownMenuCheckboxItem
+                key={column.id}
+                className='capitalize'
+                checked={column.getIsVisible()}
+                onCheckedChange={(value) => column.toggleVisibility(!!value)}
+              >
+                {column.id}
+              </DropdownMenuCheckboxItem>
+            )
+          })}
+      </DropdownMenuContent>
+    </DropdownMenu>
+  )
+}

+ 24 - 0
src/features/users/components/user-primary-actions.tsx

@@ -0,0 +1,24 @@
+import { useState } from 'react'
+import { IconMailPlus, IconUserPlus } from '@tabler/icons-react'
+import { Button } from '@/components/ui/button'
+import { UsersActionDialog } from './users-action-dialog'
+
+export function UserPrimaryActions() {
+  const [open, setOpen] = useState(false)
+  return (
+    <>
+      <div className='flex gap-2'>
+        <Button variant='outline'>
+          Invite User
+          <IconMailPlus />
+        </Button>
+        <Button onClick={() => setOpen(true)}>
+          Add User
+          <IconUserPlus />
+        </Button>
+      </div>
+
+      <UsersActionDialog key='user-add' open={open} onOpenChange={setOpen} />
+    </>
+  )
+}

+ 321 - 0
src/features/users/components/users-action-dialog.tsx

@@ -0,0 +1,321 @@
+'use client'
+
+import { z } from 'zod'
+import { useForm } from 'react-hook-form'
+import { zodResolver } from '@hookform/resolvers/zod'
+import { toast } from '@/hooks/use-toast'
+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 { userTypes } from '../data/data'
+import { User } from '../data/schema'
+
+const formSchema = z
+  .object({
+    firstName: z.string().min(1, { message: 'First Name is required.' }),
+    lastName: z.string().min(1, { message: 'Last Name is required.' }),
+    username: z.string().min(1, { message: 'Username is required.' }),
+    phoneNumber: z.string().min(1, { message: 'Phone number is required.' }),
+    email: z
+      .string()
+      .min(1, { message: 'Email is required.' })
+      .email({ message: 'Email is invalid.' }),
+    password: z.string().transform((pwd) => pwd.trim()),
+    role: z.string().min(1, { message: 'Role is required.' }),
+    confirmPassword: z.string().transform((pwd) => pwd.trim()),
+    isEdit: z.boolean(),
+  })
+  .superRefine(({ isEdit, password, confirmPassword }, ctx) => {
+    if (!isEdit || (isEdit && password !== '')) {
+      if (password === '') {
+        ctx.addIssue({
+          code: z.ZodIssueCode.custom,
+          message: 'Password is required.',
+          path: ['password'],
+        })
+      }
+
+      if (password.length < 8) {
+        ctx.addIssue({
+          code: z.ZodIssueCode.custom,
+          message: 'Password must be at least 8 characters long.',
+          path: ['password'],
+        })
+      }
+
+      if (!password.match(/[a-z]/)) {
+        ctx.addIssue({
+          code: z.ZodIssueCode.custom,
+          message: 'Password must contain at least one lowercase letter.',
+          path: ['password'],
+        })
+      }
+
+      if (!password.match(/\d/)) {
+        ctx.addIssue({
+          code: z.ZodIssueCode.custom,
+          message: 'Password must contain at least one number.',
+          path: ['password'],
+        })
+      }
+
+      if (password !== confirmPassword) {
+        ctx.addIssue({
+          code: z.ZodIssueCode.custom,
+          message: "Passwords don't match.",
+          path: ['confirmPassword'],
+        })
+      }
+    }
+  })
+type UserForm = z.infer<typeof formSchema>
+
+interface Props {
+  currentRow?: User
+  open: boolean
+  onOpenChange: (open: boolean) => void
+}
+
+export function UsersActionDialog({ currentRow, open, onOpenChange }: Props) {
+  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()
+    toast({
+      title: 'You submitted the following values:',
+      description: (
+        <pre className='mt-2 w-[340px] rounded-md bg-slate-950 p-4'>
+          <code className='text-white'>{JSON.stringify(values, null, 2)}</code>
+        </pre>
+      ),
+    })
+    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>
+          <DialogTitle>{isEdit ? 'Edit User' : 'Add New User'}</DialogTitle>
+          <DialogDescription>
+            {isEdit ? 'Update the user here. ' : 'Create new user here. '}
+            Click save when you&apos;re done.
+          </DialogDescription>
+        </DialogHeader>
+        <Form {...form}>
+          <form
+            id='user-form'
+            onSubmit={form.handleSubmit(onSubmit)}
+            className='space-y-4'
+          >
+            <FormField
+              control={form.control}
+              name='firstName'
+              render={({ field }) => (
+                <FormItem className='grid grid-cols-6 items-center gap-x-4 gap-y-1 space-y-0'>
+                  <FormLabel className='col-span-2 text-right'>
+                    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 gap-x-4 gap-y-1 space-y-0'>
+                  <FormLabel className='col-span-2 text-right'>
+                    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 gap-x-4 gap-y-1 space-y-0'>
+                  <FormLabel className='col-span-2 text-right'>
+                    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 gap-x-4 gap-y-1 space-y-0'>
+                  <FormLabel className='col-span-2 text-right'>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 gap-x-4 gap-y-1 space-y-0'>
+                  <FormLabel className='col-span-2 text-right'>
+                    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 gap-x-4 gap-y-1 space-y-0'>
+                  <FormLabel className='col-span-2 text-right'>Role</FormLabel>
+                  <SelectDropdown
+                    defaultValue={field.value}
+                    onValueChange={field.onChange}
+                    placeholder='Select a role'
+                    className='col-span-4'
+                    items={userTypes.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 gap-x-4 gap-y-1 space-y-0'>
+                  <FormLabel className='col-span-2 text-right'>
+                    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 gap-x-4 gap-y-1 space-y-0'>
+                  <FormLabel className='col-span-2 text-right'>
+                    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>
+        <DialogFooter>
+          <Button type='submit' form='user-form'>
+            Save changes
+          </Button>
+        </DialogFooter>
+      </DialogContent>
+    </Dialog>
+  )
+}

+ 138 - 0
src/features/users/components/users-columns.tsx

@@ -0,0 +1,138 @@
+import { ColumnDef } from '@tanstack/react-table'
+import { cn } from '@/lib/utils'
+import { Badge } from '@/components/ui/badge'
+import { Checkbox } from '@/components/ui/checkbox'
+import LongText from '@/components/long-text'
+import { callTypes, userTypes } from '../data/data'
+import { User } from '../data/schema'
+import { DataTableColumnHeader } from './data-table-column-header'
+import { DataTableRowActions } from './data-table-row-actions'
+
+export const columns: 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(
+        'sticky md:table-cell left-0 z-10 rounded-md',
+        'bg-background group-hover/row:bg-muted group-hover/row: group-data-[state=selected]/row:bg-muted'
+      ),
+    },
+    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'>{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)] lg:drop-shadow-none',
+        'bg-background group-hover/row:bg-muted group-data-[state=selected]/row:bg-muted',
+        'sticky left-6 md:table-cell'
+      ),
+    },
+    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 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: 'weakEquals',
+    enableSorting: false,
+    enableHiding: false,
+  },
+  {
+    accessorKey: 'role',
+    header: ({ column }) => (
+      <DataTableColumnHeader column={column} title='Role' />
+    ),
+    cell: ({ row }) => {
+      const { role } = row.original
+      const userType = userTypes.find(({ value }) => value === role)
+
+      if (!userType) {
+        return null
+      }
+
+      return (
+        <div className='flex gap-x-2 items-center'>
+          {userType.icon && (
+            <userType.icon size={16} className='text-muted-foreground' />
+          )}
+          <span className='capitalize text-sm'>{row.getValue('role')}</span>
+        </div>
+      )
+    },
+    filterFn: 'weakEquals',
+    enableSorting: false,
+    enableHiding: false,
+  },
+  {
+    id: 'actions',
+    cell: DataTableRowActions,
+  },
+]

+ 86 - 0
src/features/users/components/users-delete-dialog.tsx

@@ -0,0 +1,86 @@
+'use client'
+
+import { useState } from 'react'
+import { IconAlertTriangle } from '@tabler/icons-react'
+import { toast } from '@/hooks/use-toast'
+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 { User } from '../data/schema'
+
+interface Props {
+  open: boolean
+  onOpenChange: (open: boolean) => void
+  currentRow: User
+}
+
+export function UsersDeleteDialog({ open, onOpenChange, currentRow }: Props) {
+  const [value, setValue] = useState('')
+
+  const handleDelete = () => {
+    if (value.trim() !== currentRow.username) return
+
+    onOpenChange(false)
+    toast({
+      title: 'The following user has been deleted:',
+      description: (
+        <pre className='mt-2 w-[340px] rounded-md bg-slate-950 p-4'>
+          <code className='text-white'>
+            {JSON.stringify(currentRow, null, 2)}
+          </code>
+        </pre>
+      ),
+    })
+  }
+
+  return (
+    <ConfirmDialog
+      open={open}
+      onOpenChange={onOpenChange}
+      handleConfirm={handleDelete}
+      disabled={value.trim() !== currentRow.username}
+      title={
+        <span className='text-destructive'>
+          <IconAlertTriangle
+            className='mr-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 carefull, this operation can not be rolled back.
+            </AlertDescription>
+          </Alert>
+        </div>
+      }
+      confirmText='Delete'
+      destructive
+    />
+  )
+}

+ 132 - 0
src/features/users/components/users-table.tsx

@@ -0,0 +1,132 @@
+import { useState } from 'react'
+import {
+  ColumnDef,
+  ColumnFiltersState,
+  RowData,
+  SortingState,
+  VisibilityState,
+  flexRender,
+  getCoreRowModel,
+  getFacetedRowModel,
+  getFacetedUniqueValues,
+  getFilteredRowModel,
+  getPaginationRowModel,
+  getSortedRowModel,
+  useReactTable,
+} from '@tanstack/react-table'
+import {
+  Table,
+  TableBody,
+  TableCell,
+  TableHead,
+  TableHeader,
+  TableRow,
+} from '@/components/ui/table'
+import { User } from '../data/schema'
+import { DataTablePagination } from './data-table-pagination'
+import { DataTableToolbar } from './data-table-toolbar'
+
+declare module '@tanstack/react-table' {
+  // eslint-disable-next-line @typescript-eslint/no-unused-vars
+  interface ColumnMeta<TData extends RowData, TValue> {
+    className: string
+  }
+}
+
+interface DataTableProps {
+  columns: ColumnDef<User>[]
+  data: User[]
+}
+
+export function UsersTable({ columns, data }: DataTableProps) {
+  const [rowSelection, setRowSelection] = useState({})
+  const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
+  const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
+  const [sorting, setSorting] = useState<SortingState>([])
+
+  const table = useReactTable({
+    data,
+    columns,
+    state: {
+      sorting,
+      columnVisibility,
+      rowSelection,
+      columnFilters,
+    },
+    enableRowSelection: true,
+    onRowSelectionChange: setRowSelection,
+    onSortingChange: setSorting,
+    onColumnFiltersChange: setColumnFilters,
+    onColumnVisibilityChange: setColumnVisibility,
+    getCoreRowModel: getCoreRowModel(),
+    getFilteredRowModel: getFilteredRowModel(),
+    getPaginationRowModel: getPaginationRowModel(),
+    getSortedRowModel: getSortedRowModel(),
+    getFacetedRowModel: getFacetedRowModel(),
+    getFacetedUniqueValues: getFacetedUniqueValues(),
+  })
+
+  return (
+    <div className='space-y-4'>
+      <DataTableToolbar table={table} />
+      <div className='rounded-md border'>
+        <Table>
+          <TableHeader>
+            {table.getHeaderGroups().map((headerGroup) => (
+              <TableRow key={headerGroup.id}>
+                {headerGroup.headers.map((header) => {
+                  return (
+                    <TableHead
+                      key={header.id}
+                      colSpan={header.colSpan}
+                      className={header.column.columnDef.meta?.className ?? ''}
+                    >
+                      {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'}
+                >
+                  {row.getVisibleCells().map((cell) => (
+                    <TableCell
+                      key={cell.id}
+                      className={cell.column.columnDef.meta?.className ?? ''}
+                    >
+                      {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} />
+    </div>
+  )
+}

+ 35 - 0
src/features/users/context/users-context.tsx

@@ -0,0 +1,35 @@
+import React from 'react'
+import { User } from '../data/schema'
+
+export type UsersDialogType = 'add' | 'edit' | 'delete'
+
+interface 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)
+
+interface Props {
+  children: React.ReactNode
+  value: UsersContextType
+}
+
+export default function UsersContextProvider({ children, value }: Props) {
+  return <UsersContext.Provider value={value}>{children}</UsersContext.Provider>
+}
+
+// eslint-disable-next-line react-refresh/only-export-components
+export const useUsersContext = () => {
+  const usersContext = React.useContext(UsersContext)
+
+  if (!usersContext) {
+    throw new Error(
+      'useUsersContext has to be used within <UsersContext.Provider>'
+    )
+  }
+
+  return usersContext
+}

+ 40 - 0
src/features/users/data/data.ts

@@ -0,0 +1,40 @@
+import {
+  IconCash,
+  IconShield,
+  IconUsersGroup,
+  IconUserShield,
+} from '@tabler/icons-react'
+import { 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 userTypes = [
+  {
+    label: 'Superadmin',
+    value: 'superadmin',
+    icon: IconShield,
+  },
+  {
+    label: 'Admin',
+    value: 'admin',
+    icon: IconUserShield,
+  },
+  {
+    label: 'Manager',
+    value: 'manager',
+    icon: IconUsersGroup,
+  },
+  {
+    label: 'Cashier',
+    value: 'cashier',
+    icon: IconCash,
+  },
+] as const

+ 33 - 0
src/features/users/data/schema.ts

@@ -0,0 +1,33 @@
+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'),
+])
+export type UserRole = z.infer<typeof userRoleSchema>
+
+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)

+ 30 - 0
src/features/users/data/users.ts

@@ -0,0 +1,30 @@
+import { faker } from '@faker-js/faker'
+
+export const users = Array.from({ length: 20 }, () => {
+  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(),
+  }
+})

+ 92 - 0
src/features/users/index.tsx

@@ -0,0 +1,92 @@
+import { useState } from 'react'
+import { IconMailPlus, IconUserPlus } from '@tabler/icons-react'
+import useDialogState from '@/hooks/use-dialog-state'
+import { Button } from '@/components/button'
+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 { UsersActionDialog } from './components/users-action-dialog'
+import { columns } from './components/users-columns'
+import { UsersDeleteDialog } from './components/users-delete-dialog'
+import { UsersTable } from './components/users-table'
+import UsersContextProvider, {
+  type UsersDialogType,
+} from './context/users-context'
+import { User, userListSchema } from './data/schema'
+import { users } from './data/users'
+
+export default function Users() {
+  // Dialog states
+  const [currentRow, setCurrentRow] = useState<User | null>(null)
+  const [open, setOpen] = useDialogState<UsersDialogType>(null)
+
+  // Parse user list
+  const userList = userListSchema.parse(users)
+
+  return (
+    <UsersContextProvider value={{ open, setOpen, currentRow, setCurrentRow }}>
+      {/* ===== Top Heading ===== */}
+      <Header sticky>
+        <Search />
+        <div className='ml-auto flex items-center space-x-4'>
+          <ThemeSwitch />
+          <ProfileDropdown />
+        </div>
+      </Header>
+
+      <Main>
+        <div className='mb-2 flex items-center justify-between space-y-2 flex-wrap'>
+          <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>
+          <div className='flex gap-2'>
+            <Button variant='outline' className='space-x-1'>
+              <span>Invite User</span> <IconMailPlus />
+            </Button>
+            <Button className='space-x-1' onClick={() => setOpen('add')}>
+              <span>Add User</span> <IconUserPlus />
+            </Button>
+          </div>
+        </div>
+        <div className='-mx-4 flex-1 overflow-auto px-4 py-1 lg:flex-row lg:space-x-12 lg:space-y-0'>
+          <UsersTable data={userList} columns={columns} />
+        </div>
+      </Main>
+
+      <UsersActionDialog
+        key='user-add'
+        open={open === 'add'}
+        onOpenChange={() => setOpen('add')}
+      />
+
+      {currentRow && (
+        <>
+          <UsersActionDialog
+            key={`user-edit-${currentRow.id}`}
+            open={open === 'edit'}
+            onOpenChange={() => {
+              setCurrentRow(null)
+              setOpen('edit')
+            }}
+            currentRow={currentRow}
+          />
+
+          <UsersDeleteDialog
+            key={`user-delete-${currentRow.id}`}
+            open={open === 'delete'}
+            onOpenChange={() => {
+              setCurrentRow(null)
+              setOpen('delete')
+            }}
+            currentRow={currentRow}
+          />
+        </>
+      )}
+    </UsersContextProvider>
+  )
+}

+ 18 - 0
src/hooks/use-dialog-state.tsx

@@ -0,0 +1,18 @@
+import { useState } from 'react'
+
+/**
+ * Custom hook for confirm dialog
+ * @param initialState string | null
+ * @returns A stateful value, and a function to update it.
+ * @example const [open, setOpen] = useDialogState<"approve" | "reject">()
+ */
+export default function useDialogState<T extends string | boolean>(
+  initialState: T | null = null
+) {
+  const [open, _setOpen] = useState<T | null>(initialState)
+
+  const setOpen = (str: T | null) =>
+    _setOpen((prev) => (prev === str ? null : str))
+
+  return [open, setOpen] as const
+}

+ 33 - 1
src/routeTree.gen.ts

@@ -35,6 +35,9 @@ const authForgotPasswordLazyImport = createFileRoute(
 const AuthenticatedSettingsRouteLazyImport = createFileRoute(
   '/_authenticated/settings',
 )()
+const AuthenticatedUsersIndexLazyImport = createFileRoute(
+  '/_authenticated/users/',
+)()
 const AuthenticatedTasksIndexLazyImport = createFileRoute(
   '/_authenticated/tasks/',
 )()
@@ -169,6 +172,15 @@ const auth500Route = auth500Import.update({
   getParentRoute: () => rootRoute,
 } as any)
 
+const AuthenticatedUsersIndexLazyRoute =
+  AuthenticatedUsersIndexLazyImport.update({
+    id: '/users/',
+    path: '/users/',
+    getParentRoute: () => AuthenticatedRouteRoute,
+  } as any).lazy(() =>
+    import('./routes/_authenticated/users/index.lazy').then((d) => d.Route),
+  )
+
 const AuthenticatedTasksIndexLazyRoute =
   AuthenticatedTasksIndexLazyImport.update({
     id: '/tasks/',
@@ -440,6 +452,13 @@ declare module '@tanstack/react-router' {
       preLoaderRoute: typeof AuthenticatedTasksIndexLazyImport
       parentRoute: typeof AuthenticatedRouteImport
     }
+    '/_authenticated/users/': {
+      id: '/_authenticated/users/'
+      path: '/users'
+      fullPath: '/users'
+      preLoaderRoute: typeof AuthenticatedUsersIndexLazyImport
+      parentRoute: typeof AuthenticatedRouteImport
+    }
   }
 }
 
@@ -479,6 +498,7 @@ interface AuthenticatedRouteRouteChildren {
   AuthenticatedChatsIndexLazyRoute: typeof AuthenticatedChatsIndexLazyRoute
   AuthenticatedHelpCenterIndexLazyRoute: typeof AuthenticatedHelpCenterIndexLazyRoute
   AuthenticatedTasksIndexLazyRoute: typeof AuthenticatedTasksIndexLazyRoute
+  AuthenticatedUsersIndexLazyRoute: typeof AuthenticatedUsersIndexLazyRoute
 }
 
 const AuthenticatedRouteRouteChildren: AuthenticatedRouteRouteChildren = {
@@ -490,6 +510,7 @@ const AuthenticatedRouteRouteChildren: AuthenticatedRouteRouteChildren = {
   AuthenticatedChatsIndexLazyRoute: AuthenticatedChatsIndexLazyRoute,
   AuthenticatedHelpCenterIndexLazyRoute: AuthenticatedHelpCenterIndexLazyRoute,
   AuthenticatedTasksIndexLazyRoute: AuthenticatedTasksIndexLazyRoute,
+  AuthenticatedUsersIndexLazyRoute: AuthenticatedUsersIndexLazyRoute,
 }
 
 const AuthenticatedRouteRouteWithChildren =
@@ -519,6 +540,7 @@ export interface FileRoutesByFullPath {
   '/help-center': typeof AuthenticatedHelpCenterIndexLazyRoute
   '/settings/': typeof AuthenticatedSettingsIndexLazyRoute
   '/tasks': typeof AuthenticatedTasksIndexLazyRoute
+  '/users': typeof AuthenticatedUsersIndexLazyRoute
 }
 
 export interface FileRoutesByTo {
@@ -543,6 +565,7 @@ export interface FileRoutesByTo {
   '/help-center': typeof AuthenticatedHelpCenterIndexLazyRoute
   '/settings': typeof AuthenticatedSettingsIndexLazyRoute
   '/tasks': typeof AuthenticatedTasksIndexLazyRoute
+  '/users': typeof AuthenticatedUsersIndexLazyRoute
 }
 
 export interface FileRoutesById {
@@ -571,6 +594,7 @@ export interface FileRoutesById {
   '/_authenticated/help-center/': typeof AuthenticatedHelpCenterIndexLazyRoute
   '/_authenticated/settings/': typeof AuthenticatedSettingsIndexLazyRoute
   '/_authenticated/tasks/': typeof AuthenticatedTasksIndexLazyRoute
+  '/_authenticated/users/': typeof AuthenticatedUsersIndexLazyRoute
 }
 
 export interface FileRouteTypes {
@@ -599,6 +623,7 @@ export interface FileRouteTypes {
     | '/help-center'
     | '/settings/'
     | '/tasks'
+    | '/users'
   fileRoutesByTo: FileRoutesByTo
   to:
     | '/500'
@@ -622,6 +647,7 @@ export interface FileRouteTypes {
     | '/help-center'
     | '/settings'
     | '/tasks'
+    | '/users'
   id:
     | '__root__'
     | '/_authenticated'
@@ -648,6 +674,7 @@ export interface FileRouteTypes {
     | '/_authenticated/help-center/'
     | '/_authenticated/settings/'
     | '/_authenticated/tasks/'
+    | '/_authenticated/users/'
   fileRoutesById: FileRoutesById
 }
 
@@ -714,7 +741,8 @@ export const routeTree = rootRoute
         "/_authenticated/apps/",
         "/_authenticated/chats/",
         "/_authenticated/help-center/",
-        "/_authenticated/tasks/"
+        "/_authenticated/tasks/",
+        "/_authenticated/users/"
       ]
     },
     "/(auth)/500": {
@@ -804,6 +832,10 @@ export const routeTree = rootRoute
     "/_authenticated/tasks/": {
       "filePath": "_authenticated/tasks/index.lazy.tsx",
       "parent": "/_authenticated"
+    },
+    "/_authenticated/users/": {
+      "filePath": "_authenticated/users/index.lazy.tsx",
+      "parent": "/_authenticated"
     }
   }
 }

+ 6 - 0
src/routes/_authenticated/users/index.lazy.tsx

@@ -0,0 +1,6 @@
+import { createLazyFileRoute } from '@tanstack/react-router'
+import Users from '@/features/users'
+
+export const Route = createLazyFileRoute('/_authenticated/users/')({
+  component: Users,
+})