🛂 Migrate frontend to Shadcn (#2010)

* 🔧 Add Tailwind, update dependencies and config files

* ✨ Introduce new Shadcn components and remove old ones

* 🔧 Update dependencies

* Add new components.json file

* 🔥 Remove Chakra UI files

* 🔧 Add ThemeProvider component and integrate it into main

* 🔥 Remove common components

* Update primary color

* ✨ Add new components

* ✨ Add AuthLayout component

* 🔧 Add utility function cn

* 🔧 Refactor devtools integration and update dependencies

* ✨ Add Footer and Error components

* ♻️ Update Footer

* 🔥 Remove utils

* ♻️ Refactor error handling in useAuth

* ♻️ Refactor useCustomToast

* ♻️ Refactor Login component and form handling

* ♻️ Refactor SignUp component and form handling

* 🔧 Update dependencies

* ♻️ Refactor RecoverPassword component and form handling

* ♻️ Refactor ResetPassword and form handling

* ♻️ Add error component to root route

* ♻️ Refactor error handling in utils

* ♻️ Update buttons

* 🍱 Add icons and logos assets

* ♻️ Refactor Sidebar component

* 🎨 Format

* ♻️ Refactor ThemeProvider

* ♻️ Refactor Common components

* 🔥 Remove old Appearance component

* ✨ Add Sidebar components

* ♻️ Refactor DeleteAccount components

* ♻️ Refactor ChangePassword component

* ♻️ Refactor UserSettings

* ✨ Add TanStack table

* ♻️ Update SignUp

* ✨ Add Select component

* 🎨 Format

* ♻️ Update Footer

* ✨ Add useCopyToClipboard hook

* 🎨 Tweak table styles

* 🎨 Tweak styling

* ♻️ Refactor AddUser and AddItem components

* ♻️ Update DeleteConfirmation

* ✅ Update tests

* ✅ Update tests

* ✅ Fix tests

* ✨ Add DataTable for item and admin management

* ♻️ Refactor DeleteUser and DeleteItem components

* ✅ Fix tests

* ♻️ Refactor EditUser and EditItem components

* ♻️ Refactor UserInformation component

* 🎨 Format

* ♻️ Refactor pending components

* 🎨 Format

* ✅ Update tests

* ✅ Update tests

* ✅ Fix test

* ♻️ Minor tweaks

* ♻️ Update social media links
This commit is contained in:
Alejandra
2025-12-07 13:21:13 +01:00
committed by GitHub
parent 61b7cd673a
commit 8c2532a5c3
104 changed files with 8891 additions and 3287 deletions
+176 -163
View File
@@ -1,48 +1,64 @@
import {
Button,
DialogActionTrigger,
DialogTitle,
Flex,
Input,
Text,
VStack,
} from "@chakra-ui/react"
import { zodResolver } from "@hookform/resolvers/zod"
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { Plus } from "lucide-react"
import { useState } from "react"
import { Controller, type SubmitHandler, useForm } from "react-hook-form"
import { FaPlus } from "react-icons/fa"
import { useForm } from "react-hook-form"
import { z } from "zod"
import { type UserCreate, UsersService } from "@/client"
import type { ApiError } from "@/client/core/ApiError"
import useCustomToast from "@/hooks/useCustomToast"
import { emailPattern, handleError } from "@/utils"
import { Checkbox } from "../ui/checkbox"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import {
DialogBody,
DialogCloseTrigger,
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogRoot,
DialogTitle,
DialogTrigger,
} from "../ui/dialog"
import { Field } from "../ui/field"
} from "@/components/ui/dialog"
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form"
import { Input } from "@/components/ui/input"
import { LoadingButton } from "@/components/ui/loading-button"
import useCustomToast from "@/hooks/useCustomToast"
import { handleError } from "@/utils"
interface UserCreateForm extends UserCreate {
confirm_password: string
}
const formSchema = z
.object({
email: z.email({ message: "Invalid email address" }),
full_name: z.string().optional(),
password: z
.string()
.min(1, { message: "Password is required" })
.min(8, { message: "Password must be at least 8 characters" }),
confirm_password: z
.string()
.min(1, { message: "Please confirm your password" }),
is_superuser: z.boolean(),
is_active: z.boolean(),
})
.refine((data) => data.password === data.confirm_password, {
message: "The passwords don't match",
path: ["confirm_password"],
})
type FormData = z.infer<typeof formSchema>
const AddUser = () => {
const [isOpen, setIsOpen] = useState(false)
const queryClient = useQueryClient()
const { showSuccessToast } = useCustomToast()
const {
control,
register,
handleSubmit,
reset,
getValues,
formState: { errors, isValid, isSubmitting },
} = useForm<UserCreateForm>({
const { showSuccessToast, showErrorToast } = useCustomToast()
const form = useForm<FormData>({
resolver: zodResolver(formSchema),
mode: "onBlur",
criteriaMode: "all",
defaultValues: {
@@ -59,166 +75,163 @@ const AddUser = () => {
mutationFn: (data: UserCreate) =>
UsersService.createUser({ requestBody: data }),
onSuccess: () => {
showSuccessToast("User created successfully.")
reset()
showSuccessToast("User created successfully")
form.reset()
setIsOpen(false)
},
onError: (err: ApiError) => {
handleError(err)
},
onError: handleError.bind(showErrorToast),
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["users"] })
},
})
const onSubmit: SubmitHandler<UserCreateForm> = (data) => {
const onSubmit = (data: FormData) => {
mutation.mutate(data)
}
return (
<DialogRoot
size={{ base: "xs", md: "md" }}
placement="center"
open={isOpen}
onOpenChange={({ open }) => setIsOpen(open)}
>
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button value="add-user" my={4}>
<FaPlus fontSize="16px" />
<Button className="my-4">
<Plus className="mr-2" />
Add User
</Button>
</DialogTrigger>
<DialogContent>
<form onSubmit={handleSubmit(onSubmit)}>
<DialogHeader>
<DialogTitle>Add User</DialogTitle>
</DialogHeader>
<DialogBody>
<Text mb={4}>
Fill in the form below to add a new user to the system.
</Text>
<VStack gap={4}>
<Field
required
invalid={!!errors.email}
errorText={errors.email?.message}
label="Email"
>
<Input
{...register("email", {
required: "Email is required",
pattern: emailPattern,
})}
placeholder="Email"
type="email"
/>
</Field>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Add User</DialogTitle>
<DialogDescription>
Fill in the form below to add a new user to the system.
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<div className="grid gap-4 py-4">
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>
Email <span className="text-destructive">*</span>
</FormLabel>
<FormControl>
<Input
placeholder="Email"
type="email"
{...field}
required
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Field
invalid={!!errors.full_name}
errorText={errors.full_name?.message}
label="Full Name"
>
<Input
{...register("full_name")}
placeholder="Full name"
type="text"
/>
</Field>
<FormField
control={form.control}
name="full_name"
render={({ field }) => (
<FormItem>
<FormLabel>Full Name</FormLabel>
<FormControl>
<Input placeholder="Full name" type="text" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Field
required
invalid={!!errors.password}
errorText={errors.password?.message}
label="Set Password"
>
<Input
{...register("password", {
required: "Password is required",
minLength: {
value: 8,
message: "Password must be at least 8 characters",
},
})}
placeholder="Password"
type="password"
/>
</Field>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>
Set Password <span className="text-destructive">*</span>
</FormLabel>
<FormControl>
<Input
placeholder="Password"
type="password"
{...field}
required
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Field
required
invalid={!!errors.confirm_password}
errorText={errors.confirm_password?.message}
label="Confirm Password"
>
<Input
{...register("confirm_password", {
required: "Please confirm your password",
validate: (value) =>
value === getValues().password ||
"The passwords do not match",
})}
placeholder="Password"
type="password"
/>
</Field>
</VStack>
<FormField
control={form.control}
name="confirm_password"
render={({ field }) => (
<FormItem>
<FormLabel>
Confirm Password{" "}
<span className="text-destructive">*</span>
</FormLabel>
<FormControl>
<Input
placeholder="Password"
type="password"
{...field}
required
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Flex mt={4} direction="column" gap={4}>
<Controller
control={control}
<FormField
control={form.control}
name="is_superuser"
render={({ field }) => (
<Field disabled={field.disabled} colorPalette="teal">
<Checkbox
checked={field.value}
onCheckedChange={({ checked }) => field.onChange(checked)}
>
Is superuser?
</Checkbox>
</Field>
<FormItem className="flex items-center gap-3 space-y-0">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormLabel className="font-normal">Is superuser?</FormLabel>
</FormItem>
)}
/>
<Controller
control={control}
<FormField
control={form.control}
name="is_active"
render={({ field }) => (
<Field disabled={field.disabled} colorPalette="teal">
<Checkbox
checked={field.value}
onCheckedChange={({ checked }) => field.onChange(checked)}
>
Is active?
</Checkbox>
</Field>
<FormItem className="flex items-center gap-3 space-y-0">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormLabel className="font-normal">Is active?</FormLabel>
</FormItem>
)}
/>
</Flex>
</DialogBody>
</div>
<DialogFooter gap={2}>
<DialogActionTrigger asChild>
<Button
variant="subtle"
colorPalette="gray"
disabled={isSubmitting}
>
Cancel
</Button>
</DialogActionTrigger>
<Button
variant="solid"
type="submit"
disabled={!isValid}
loading={isSubmitting}
>
Save
</Button>
</DialogFooter>
</form>
<DialogCloseTrigger />
<DialogFooter>
<DialogClose asChild>
<Button variant="outline" disabled={mutation.isPending}>
Cancel
</Button>
</DialogClose>
<LoadingButton type="submit" loading={mutation.isPending}>
Save
</LoadingButton>
</DialogFooter>
</form>
</Form>
</DialogContent>
</DialogRoot>
</Dialog>
)
}
+40 -49
View File
@@ -1,30 +1,34 @@
import { Button, DialogTitle, Text } from "@chakra-ui/react"
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { Trash2 } from "lucide-react"
import { useState } from "react"
import { useForm } from "react-hook-form"
import { FiTrash2 } from "react-icons/fi"
import { UsersService } from "@/client"
import { Button } from "@/components/ui/button"
import {
DialogActionTrigger,
DialogBody,
DialogCloseTrigger,
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogRoot,
DialogTrigger,
DialogTitle,
} from "@/components/ui/dialog"
import { DropdownMenuItem } from "@/components/ui/dropdown-menu"
import { LoadingButton } from "@/components/ui/loading-button"
import useCustomToast from "@/hooks/useCustomToast"
import { handleError } from "@/utils"
const DeleteUser = ({ id }: { id: string }) => {
interface DeleteUserProps {
id: string
onSuccess: () => void
}
const DeleteUser = ({ id, onSuccess }: DeleteUserProps) => {
const [isOpen, setIsOpen] = useState(false)
const queryClient = useQueryClient()
const { showSuccessToast, showErrorToast } = useCustomToast()
const {
handleSubmit,
formState: { isSubmitting },
} = useForm()
const { handleSubmit } = useForm()
const deleteUser = async (id: string) => {
await UsersService.deleteUser({ userId: id })
@@ -35,10 +39,9 @@ const DeleteUser = ({ id }: { id: string }) => {
onSuccess: () => {
showSuccessToast("The user was deleted successfully")
setIsOpen(false)
onSuccess()
},
onError: () => {
showErrorToast("An error occurred while deleting the user")
},
onError: handleError.bind(showErrorToast),
onSettled: () => {
queryClient.invalidateQueries()
},
@@ -49,55 +52,43 @@ const DeleteUser = ({ id }: { id: string }) => {
}
return (
<DialogRoot
size={{ base: "xs", md: "md" }}
placement="center"
role="alertdialog"
open={isOpen}
onOpenChange={({ open }) => setIsOpen(open)}
>
<DialogTrigger asChild>
<Button variant="ghost" size="sm" colorPalette="red">
<FiTrash2 fontSize="16px" />
Delete User
</Button>
</DialogTrigger>
<DialogContent>
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DropdownMenuItem
variant="destructive"
onSelect={(e) => e.preventDefault()}
onClick={() => setIsOpen(true)}
>
<Trash2 />
Delete User
</DropdownMenuItem>
<DialogContent className="sm:max-w-md">
<form onSubmit={handleSubmit(onSubmit)}>
<DialogHeader>
<DialogTitle>Delete User</DialogTitle>
</DialogHeader>
<DialogBody>
<Text mb={4}>
<DialogDescription>
All items associated with this user will also be{" "}
<strong>permanently deleted.</strong> Are you sure? You will not
be able to undo this action.
</Text>
</DialogBody>
</DialogDescription>
</DialogHeader>
<DialogFooter gap={2}>
<DialogActionTrigger asChild>
<Button
variant="subtle"
colorPalette="gray"
disabled={isSubmitting}
>
<DialogFooter className="mt-4">
<DialogClose asChild>
<Button variant="outline" disabled={mutation.isPending}>
Cancel
</Button>
</DialogActionTrigger>
<Button
variant="solid"
colorPalette="red"
</DialogClose>
<LoadingButton
variant="destructive"
type="submit"
loading={isSubmitting}
loading={mutation.isPending}
>
Delete
</Button>
</LoadingButton>
</DialogFooter>
<DialogCloseTrigger />
</form>
</DialogContent>
</DialogRoot>
</Dialog>
)
}
+188 -164
View File
@@ -1,214 +1,238 @@
import {
Button,
DialogActionTrigger,
DialogRoot,
DialogTrigger,
Flex,
Input,
Text,
VStack,
} from "@chakra-ui/react"
import { zodResolver } from "@hookform/resolvers/zod"
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { Pencil } from "lucide-react"
import { useState } from "react"
import { Controller, type SubmitHandler, useForm } from "react-hook-form"
import { FaExchangeAlt } from "react-icons/fa"
import { useForm } from "react-hook-form"
import { z } from "zod"
import { type UserPublic, UsersService, type UserUpdate } from "@/client"
import type { ApiError } from "@/client/core/ApiError"
import useCustomToast from "@/hooks/useCustomToast"
import { emailPattern, handleError } from "@/utils"
import { Checkbox } from "../ui/checkbox"
import { type UserPublic, UsersService } from "@/client"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import {
DialogBody,
DialogCloseTrigger,
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "../ui/dialog"
import { Field } from "../ui/field"
} from "@/components/ui/dialog"
import { DropdownMenuItem } from "@/components/ui/dropdown-menu"
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form"
import { Input } from "@/components/ui/input"
import { LoadingButton } from "@/components/ui/loading-button"
import useCustomToast from "@/hooks/useCustomToast"
import { handleError } from "@/utils"
const formSchema = z
.object({
email: z.email({ message: "Invalid email address" }),
full_name: z.string().optional(),
password: z
.string()
.min(8, { message: "Password must be at least 8 characters" })
.optional()
.or(z.literal("")),
confirm_password: z.string().optional(),
is_superuser: z.boolean().optional(),
is_active: z.boolean().optional(),
})
.refine((data) => !data.password || data.password === data.confirm_password, {
message: "The passwords don't match",
path: ["confirm_password"],
})
type FormData = z.infer<typeof formSchema>
interface EditUserProps {
user: UserPublic
onSuccess: () => void
}
interface UserUpdateForm extends UserUpdate {
confirm_password?: string
}
const EditUser = ({ user }: EditUserProps) => {
const EditUser = ({ user, onSuccess }: EditUserProps) => {
const [isOpen, setIsOpen] = useState(false)
const queryClient = useQueryClient()
const { showSuccessToast } = useCustomToast()
const {
control,
register,
handleSubmit,
reset,
getValues,
formState: { errors, isSubmitting },
} = useForm<UserUpdateForm>({
const { showSuccessToast, showErrorToast } = useCustomToast()
const form = useForm<FormData>({
resolver: zodResolver(formSchema),
mode: "onBlur",
criteriaMode: "all",
defaultValues: user,
defaultValues: {
email: user.email,
full_name: user.full_name ?? undefined,
is_superuser: user.is_superuser,
is_active: user.is_active,
},
})
const mutation = useMutation({
mutationFn: (data: UserUpdateForm) =>
mutationFn: (data: FormData) =>
UsersService.updateUser({ userId: user.id, requestBody: data }),
onSuccess: () => {
showSuccessToast("User updated successfully.")
reset()
showSuccessToast("User updated successfully")
setIsOpen(false)
onSuccess()
},
onError: (err: ApiError) => {
handleError(err)
},
onError: handleError.bind(showErrorToast),
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["users"] })
},
})
const onSubmit: SubmitHandler<UserUpdateForm> = async (data) => {
if (data.password === "") {
data.password = undefined
const onSubmit = (data: FormData) => {
// exclude confirm_password from submission data and remove password if empty
const { confirm_password: _, ...submitData } = data
if (!submitData.password) {
delete submitData.password
}
mutation.mutate(data)
mutation.mutate(submitData)
}
return (
<DialogRoot
size={{ base: "xs", md: "md" }}
placement="center"
open={isOpen}
onOpenChange={({ open }) => setIsOpen(open)}
>
<DialogTrigger asChild>
<Button variant="ghost" size="sm">
<FaExchangeAlt fontSize="16px" />
Edit User
</Button>
</DialogTrigger>
<DialogContent>
<form onSubmit={handleSubmit(onSubmit)}>
<DialogHeader>
<DialogTitle>Edit User</DialogTitle>
</DialogHeader>
<DialogBody>
<Text mb={4}>Update the user details below.</Text>
<VStack gap={4}>
<Field
required
invalid={!!errors.email}
errorText={errors.email?.message}
label="Email"
>
<Input
{...register("email", {
required: "Email is required",
pattern: emailPattern,
})}
placeholder="Email"
type="email"
/>
</Field>
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DropdownMenuItem
onSelect={(e) => e.preventDefault()}
onClick={() => setIsOpen(true)}
>
<Pencil />
Edit User
</DropdownMenuItem>
<DialogContent className="sm:max-w-md">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<DialogHeader>
<DialogTitle>Edit User</DialogTitle>
<DialogDescription>
Update the user details below.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>
Email <span className="text-destructive">*</span>
</FormLabel>
<FormControl>
<Input
placeholder="Email"
type="email"
{...field}
required
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Field
invalid={!!errors.full_name}
errorText={errors.full_name?.message}
label="Full Name"
>
<Input
{...register("full_name")}
placeholder="Full name"
type="text"
/>
</Field>
<FormField
control={form.control}
name="full_name"
render={({ field }) => (
<FormItem>
<FormLabel>Full Name</FormLabel>
<FormControl>
<Input placeholder="Full name" type="text" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Field
invalid={!!errors.password}
errorText={errors.password?.message}
label="Set Password"
>
<Input
{...register("password", {
minLength: {
value: 8,
message: "Password must be at least 8 characters",
},
})}
placeholder="Password"
type="password"
/>
</Field>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Set Password</FormLabel>
<FormControl>
<Input
placeholder="Password"
type="password"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Field
invalid={!!errors.confirm_password}
errorText={errors.confirm_password?.message}
label="Confirm Password"
>
<Input
{...register("confirm_password", {
validate: (value) =>
value === getValues().password ||
"The passwords do not match",
})}
placeholder="Password"
type="password"
/>
</Field>
</VStack>
<FormField
control={form.control}
name="confirm_password"
render={({ field }) => (
<FormItem>
<FormLabel>Confirm Password</FormLabel>
<FormControl>
<Input
placeholder="Password"
type="password"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Flex mt={4} direction="column" gap={4}>
<Controller
control={control}
<FormField
control={form.control}
name="is_superuser"
render={({ field }) => (
<Field disabled={field.disabled} colorPalette="teal">
<Checkbox
checked={field.value}
onCheckedChange={({ checked }) => field.onChange(checked)}
>
Is superuser?
</Checkbox>
</Field>
<FormItem className="flex items-center gap-3 space-y-0">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormLabel className="font-normal">Is superuser?</FormLabel>
</FormItem>
)}
/>
<Controller
control={control}
<FormField
control={form.control}
name="is_active"
render={({ field }) => (
<Field disabled={field.disabled} colorPalette="teal">
<Checkbox
checked={field.value}
onCheckedChange={({ checked }) => field.onChange(checked)}
>
Is active?
</Checkbox>
</Field>
<FormItem className="flex items-center gap-3 space-y-0">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormLabel className="font-normal">Is active?</FormLabel>
</FormItem>
)}
/>
</Flex>
</DialogBody>
</div>
<DialogFooter gap={2}>
<DialogActionTrigger asChild>
<Button
variant="subtle"
colorPalette="gray"
disabled={isSubmitting}
>
Cancel
</Button>
</DialogActionTrigger>
<Button variant="solid" type="submit" loading={isSubmitting}>
Save
</Button>
</DialogFooter>
<DialogCloseTrigger />
</form>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline" disabled={mutation.isPending}>
Cancel
</Button>
</DialogClose>
<LoadingButton type="submit" loading={mutation.isPending}>
Save
</LoadingButton>
</DialogFooter>
</form>
</Form>
</DialogContent>
</DialogRoot>
</Dialog>
)
}
@@ -0,0 +1,40 @@
import { EllipsisVertical } from "lucide-react"
import { useState } from "react"
import type { UserPublic } from "@/client"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import useAuth from "@/hooks/useAuth"
import DeleteUser from "./DeleteUser"
import EditUser from "./EditUser"
interface UserActionsMenuProps {
user: UserPublic
}
export const UserActionsMenu = ({ user }: UserActionsMenuProps) => {
const [open, setOpen] = useState(false)
const { user: currentUser } = useAuth()
if (user.id === currentUser?.id) {
return null
}
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<EllipsisVertical />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<EditUser user={user} onSuccess={() => setOpen(false)} />
<DeleteUser id={user.id} onSuccess={() => setOpen(false)} />
</DropdownMenuContent>
</DropdownMenu>
)
}
+76
View File
@@ -0,0 +1,76 @@
import type { ColumnDef } from "@tanstack/react-table"
import type { UserPublic } from "@/client"
import { Badge } from "@/components/ui/badge"
import { cn } from "@/lib/utils"
import { UserActionsMenu } from "./UserActionsMenu"
export type UserTableData = UserPublic & {
isCurrentUser: boolean
}
export const columns: ColumnDef<UserTableData>[] = [
{
accessorKey: "full_name",
header: "Full Name",
cell: ({ row }) => {
const fullName = row.original.full_name
return (
<div className="flex items-center gap-2">
<span
className={cn("font-medium", !fullName && "text-muted-foreground")}
>
{fullName || "N/A"}
</span>
{row.original.isCurrentUser && (
<Badge variant="outline" className="text-xs">
You
</Badge>
)}
</div>
)
},
},
{
accessorKey: "email",
header: "Email",
cell: ({ row }) => (
<span className="text-muted-foreground">{row.original.email}</span>
),
},
{
accessorKey: "is_superuser",
header: "Role",
cell: ({ row }) => (
<Badge variant={row.original.is_superuser ? "default" : "secondary"}>
{row.original.is_superuser ? "Superuser" : "User"}
</Badge>
),
},
{
accessorKey: "is_active",
header: "Status",
cell: ({ row }) => (
<div className="flex items-center gap-2">
<span
className={cn(
"size-2 rounded-full",
row.original.is_active ? "bg-green-500" : "bg-gray-400",
)}
/>
<span className={row.original.is_active ? "" : "text-muted-foreground"}>
{row.original.is_active ? "Active" : "Inactive"}
</span>
</div>
),
},
{
id: "actions",
header: () => <span className="sr-only">Actions</span>,
cell: ({ row }) => (
<div className="flex justify-end">
<UserActionsMenu user={row.original} />
</div>
),
},
]