87 | >(({ className, ...props }, ref) => (
88 | [role=checkbox]]:translate-y-[2px]',
92 | className
93 | )}
94 | {...props}
95 | />
96 | ));
97 | TableCell.displayName = 'TableCell';
98 |
99 | const TableCaption = React.forwardRef<
100 | HTMLTableCaptionElement,
101 | React.HTMLAttributes
102 | >(({ className, ...props }, ref) => (
103 |
108 | ));
109 | TableCaption.displayName = 'TableCaption';
110 |
111 | export {
112 | Table,
113 | TableHeader,
114 | TableBody,
115 | TableFooter,
116 | TableHead,
117 | TableRow,
118 | TableCell,
119 | TableCaption
120 | };
121 |
--------------------------------------------------------------------------------
/src/components/protected/tasks/table/tasks-table-columns.tsx:
--------------------------------------------------------------------------------
1 | import { type Task } from '@prisma/client';
2 | import { type ColumnDef } from '@tanstack/react-table';
3 |
4 | import { formatDate } from '@/lib/utils';
5 | import { Badge } from '@/components/ui/badge';
6 | import { Checkbox } from '@/components/ui/checkbox';
7 | import { DataTableColumnHeader } from '@/components/data-table/data-table-column-header';
8 | import { TasksTableCellActions } from '@/components/protected/tasks/table/tasks-table-cell-actions';
9 |
10 | type TaskWithProject = Task & {
11 | project: string;
12 | };
13 |
14 | export function getColumns(): ColumnDef[] {
15 | return [
16 | {
17 | id: 'select',
18 | header: ({ table }) => (
19 | table.toggleAllPageRowsSelected(!!value)}
25 | aria-label='Select all'
26 | className='translate-y-0.5'
27 | />
28 | ),
29 | cell: ({ row }) => (
30 | row.toggleSelected(!!value)}
33 | aria-label='Select row'
34 | className='translate-y-0.5'
35 | />
36 | ),
37 | enableSorting: false,
38 | enableHiding: false
39 | },
40 | {
41 | accessorKey: 'title',
42 | header: ({ column }) => (
43 |
44 | ),
45 | cell: ({ row }) => (
46 |
47 |
48 | {row.getValue('title')}
49 |
50 |
51 | )
52 | },
53 | {
54 | accessorKey: 'project',
55 | header: ({ column }) => (
56 |
57 | ),
58 | cell: ({ row }) => row.getValue('project') || 'N/A'
59 | },
60 | {
61 | accessorKey: 'status',
62 | header: ({ column }) => (
63 |
64 | ),
65 | cell: ({ row }) => (
66 |
67 | {row.original.status.charAt(0).toUpperCase() +
68 | row.original.status.slice(1).toLowerCase()}
69 |
70 | )
71 | },
72 | {
73 | accessorKey: 'priority',
74 | header: ({ column }) => (
75 |
76 | ),
77 | cell: ({ row }) => (
78 |
79 | {row.original.priority.charAt(0).toUpperCase() +
80 | row.original.priority.slice(1).toLowerCase()}
81 |
82 | )
83 | },
84 | {
85 | accessorKey: 'label',
86 | header: ({ column }) => (
87 |
88 | ),
89 | cell: ({ row }) => (
90 |
91 | {row.original.label.charAt(0).toUpperCase() +
92 | row.original.label.slice(1).toLowerCase()}
93 |
94 | )
95 | },
96 | {
97 | accessorKey: 'createdAt',
98 | header: ({ column }) => (
99 |
100 | ),
101 | cell: ({ cell }) => formatDate(cell.getValue() as Date)
102 | },
103 | {
104 | id: 'actions',
105 | cell: ({ row }) =>
106 | }
107 | ];
108 | }
109 |
--------------------------------------------------------------------------------
/src/components/data-table/data-table-column-header.tsx:
--------------------------------------------------------------------------------
1 | import type { Column } from '@tanstack/react-table';
2 | import {
3 | ArrowUpIcon,
4 | EyeNoneIcon,
5 | ArrowDownIcon,
6 | CaretSortIcon
7 | } from '@radix-ui/react-icons';
8 |
9 | import { cn } from '@/lib/utils';
10 | import { Button } from '@/components/ui/button';
11 | import {
12 | DropdownMenu,
13 | DropdownMenuItem,
14 | DropdownMenuTrigger,
15 | DropdownMenuContent,
16 | DropdownMenuSeparator
17 | } from '@/components/ui/dropdown-menu';
18 |
19 | interface DataTableColumnHeaderProps
20 | extends React.HTMLAttributes {
21 | column: Column;
22 | title: string;
23 | }
24 |
25 | export function DataTableColumnHeader({
26 | column,
27 | title,
28 | className
29 | }: DataTableColumnHeaderProps) {
30 | if (!column.getCanSort() && !column.getCanHide()) {
31 | return {title}
;
32 | }
33 |
34 | return (
35 |
36 |
37 |
38 |
50 | {title}
51 | {column.getCanSort() && column.getIsSorted() === 'desc' ? (
52 |
53 | ) : column.getIsSorted() === 'asc' ? (
54 |
55 | ) : (
56 |
57 | )}
58 |
59 |
60 |
61 | {column.getCanSort() && (
62 | <>
63 | column.toggleSorting(false)}
66 | >
67 |
71 | Asc
72 |
73 | column.toggleSorting(true)}
76 | >
77 |
81 | Desc
82 |
83 | >
84 | )}
85 | {column.getCanSort() && column.getCanHide() && (
86 |
87 | )}
88 | {column.getCanHide() && (
89 | column.toggleVisibility(false)}
92 | >
93 |
97 | Hide
98 |
99 | )}
100 |
101 |
102 |
103 | );
104 | }
105 |
--------------------------------------------------------------------------------
/src/auth.ts:
--------------------------------------------------------------------------------
1 | import NextAuth from 'next-auth';
2 | import type { UserRole } from '@prisma/client';
3 | import { PrismaAdapter } from '@auth/prisma-adapter';
4 |
5 | import { db } from '@/lib/db';
6 | import authConfig from '@/auth.config';
7 | import { getUserById } from '@/data/user';
8 | import { getAccountByUserId } from '@/data/account';
9 | import { getTwoFactorConfirmationByUserId } from '@/data/two-factor-confirmation';
10 |
11 | export const { handlers, auth, signIn, signOut, unstable_update } = NextAuth({
12 | pages: {
13 | signIn: '/auth/sign-in',
14 | error: '/auth/sign-in'
15 | },
16 | events: {
17 | async linkAccount({ user }) {
18 | await db.user.update({
19 | where: {
20 | id: user.id
21 | },
22 | data: {
23 | emailVerified: new Date()
24 | }
25 | });
26 | }
27 | },
28 | callbacks: {
29 | async signIn({ user, account }) {
30 | // Skip email verification check for OAuth
31 | if (account?.provider !== 'credentials') {
32 | return true;
33 | }
34 |
35 | // Check if user exists
36 | if (!user || !user.id) {
37 | return false;
38 | }
39 |
40 | // Get existing user
41 | const existingUser = await getUserById(user.id);
42 |
43 | // Prevent unverified email sign in
44 | if (!existingUser || !existingUser.emailVerified) {
45 | return false;
46 | }
47 |
48 | // Prevent user with force new password to sign in
49 | if (existingUser.isForceNewPassword) {
50 | return false;
51 | }
52 |
53 | // Check if 2FA enabled
54 | if (existingUser.isTwoFactorEnabled) {
55 | const twoFactorConfirmation = await getTwoFactorConfirmationByUserId(
56 | existingUser.id
57 | );
58 |
59 | // Prevent unconfirmed 2FA sign in
60 | if (!twoFactorConfirmation) {
61 | return false;
62 | }
63 |
64 | // Delete 2FA confirmation for next sign in
65 | await db.twoFactorConfirmation.delete({
66 | where: {
67 | id: twoFactorConfirmation.id
68 | }
69 | });
70 | }
71 |
72 | return true;
73 | },
74 | async session({ token, session }) {
75 | if (token.sub && session.user) {
76 | session.user.id = token.sub;
77 | }
78 |
79 | if (token.role && session.user) {
80 | session.user.role = token.role as UserRole;
81 | }
82 |
83 | if (session.user) {
84 | session.user.isTwoFactorEnabled = token.isTwoFactorEnabled as boolean;
85 | }
86 |
87 | if (session.user) {
88 | session.user.name = token.name;
89 | session.user.email = token.email as string;
90 | session.user.tempEmail = token.tempEmail as string | null;
91 | session.user.isOAuth = token.isOAuth as boolean;
92 | }
93 |
94 | return session;
95 | },
96 | async jwt({ token }) {
97 | if (!token.sub) {
98 | return token;
99 | }
100 |
101 | const existingUser = await getUserById(token.sub);
102 |
103 | if (!existingUser) {
104 | return token;
105 | }
106 |
107 | const existingAccount = await getAccountByUserId(existingUser.id);
108 |
109 | token.isOAuth = !!existingAccount;
110 | token.name = existingUser.name;
111 | token.email = existingUser.email;
112 | token.tempEmail = existingUser.tempEmail;
113 | token.role = existingUser.role;
114 | token.isTwoFactorEnabled = existingUser.isTwoFactorEnabled;
115 |
116 | return token;
117 | }
118 | },
119 | ...authConfig,
120 | adapter: PrismaAdapter(db),
121 | session: { strategy: 'jwt' },
122 | secret: process.env.AUTH_SECRET,
123 | debug: process.env.NODE_ENV !== 'production'
124 | });
125 |
--------------------------------------------------------------------------------
/src/components/protected/projects/edit-project-form.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import * as z from 'zod';
4 | import { toast } from 'sonner';
5 | import { useTransition } from 'react';
6 | import { Loader2 } from 'lucide-react';
7 | import { useForm } from 'react-hook-form';
8 | import { useRouter } from 'next/navigation';
9 | import { type Project } from '@prisma/client';
10 | import { zodResolver } from '@hookform/resolvers/zod';
11 |
12 | import { Input } from '@/components/ui/input';
13 | import { EditProjectSchema } from '@/schemas';
14 | import { Button } from '@/components/ui/button';
15 | import { Textarea } from '@/components/ui/textarea';
16 | import { editProject } from '@/actions/projects/edit-project';
17 | import {
18 | Form,
19 | FormItem,
20 | FormField,
21 | FormLabel,
22 | FormControl,
23 | FormMessage
24 | } from '@/components/ui/form';
25 |
26 | interface EditProjectFormProps {
27 | project: Project | null;
28 | }
29 |
30 | export function EditProjectForm({ project }: EditProjectFormProps) {
31 | const router = useRouter();
32 | const [isPending, startTransition] = useTransition();
33 |
34 | const form = useForm>({
35 | resolver: zodResolver(EditProjectSchema),
36 | defaultValues: {
37 | id: project?.id || '',
38 | name: project?.name || '',
39 | description: project?.description || ''
40 | }
41 | });
42 |
43 | const onSubmit = (values: z.infer) => {
44 | startTransition(() => {
45 | editProject(values)
46 | .then((data) => {
47 | if (data.error) {
48 | toast.error(data.error);
49 | }
50 | if (data.success) {
51 | toast.success(data.success);
52 | router.refresh();
53 | }
54 | })
55 | .catch(() => toast.error('Uh oh! Something went wrong.'));
56 | });
57 | };
58 |
59 | return (
60 |
117 |
118 | );
119 | }
120 |
--------------------------------------------------------------------------------
/src/components/auth/forgot-password-form.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import * as z from 'zod';
4 | import Link from 'next/link';
5 | import { useForm } from 'react-hook-form';
6 | import { useState, useTransition } from 'react';
7 | import { ChevronLeft, Loader2 } from 'lucide-react';
8 | import { zodResolver } from '@hookform/resolvers/zod';
9 |
10 | import { Input } from '@/components/ui/input';
11 | import { Button } from '@/components/ui/button';
12 | import { ForgotPasswordSchema } from '@/schemas';
13 | import { FormError } from '@/components/form-error';
14 | import { FormSuccess } from '@/components/form-success';
15 | import { forgotPassword } from '@/actions/auth/forgot-password';
16 | import {
17 | Form,
18 | FormControl,
19 | FormField,
20 | FormItem,
21 | FormLabel,
22 | FormMessage
23 | } from '@/components/ui/form';
24 |
25 | export function ForgotPasswordForm() {
26 | const [isPending, startTransition] = useTransition();
27 | const [error, setError] = useState('');
28 | const [success, setSuccess] = useState('');
29 |
30 | const form = useForm>({
31 | resolver: zodResolver(ForgotPasswordSchema),
32 | defaultValues: {
33 | email: ''
34 | }
35 | });
36 |
37 | const onSubmit = async (values: z.infer) => {
38 | setError('');
39 | setSuccess('');
40 |
41 | startTransition(() => {
42 | forgotPassword(values).then((data) => {
43 | if (data.error) {
44 | setError(data.error);
45 | }
46 |
47 | if (data.success) {
48 | form.reset();
49 | setSuccess(data.success);
50 | }
51 | });
52 | });
53 | };
54 |
55 | return (
56 | <>
57 |
58 |
Forgot password?
59 |
60 | Enter your email to get a password reset link.
61 |
62 |
63 |
64 |
65 |
66 |
70 | (
74 |
75 | Email
76 |
77 |
83 |
84 |
85 |
86 | )}
87 | />
88 |
89 |
96 | {isPending && (
97 | <>
98 |
99 | Sending email...
100 | >
101 | )}
102 | {!isPending && <>Send password reset link>}
103 |
104 |
105 |
106 |
107 |
108 | Back to Sign in
109 |
110 |
111 |
112 |
113 |
114 | >
115 | );
116 | }
117 |
--------------------------------------------------------------------------------
/src/components/protected/projects/assigned-user-action.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { toast } from 'sonner';
4 | import { Loader2 } from 'lucide-react';
5 | import { useRouter } from 'next/navigation';
6 | import { useState, useTransition } from 'react';
7 | import { DotsHorizontalIcon, TrashIcon } from '@radix-ui/react-icons';
8 |
9 | import { Button } from '@/components/ui/button';
10 | import { unassignUser } from '@/actions/projects/unassign-user';
11 | import {
12 | Dialog,
13 | DialogClose,
14 | DialogTitle,
15 | DialogFooter,
16 | DialogHeader,
17 | DialogContent,
18 | DialogDescription
19 | } from '@/components/ui/dialog';
20 | import {
21 | DropdownMenu,
22 | DropdownMenuItem,
23 | DropdownMenuContent,
24 | DropdownMenuTrigger
25 | } from '@/components/ui/dropdown-menu';
26 |
27 | interface AssignedUserActionProps {
28 | userId: string;
29 | projectId: string | undefined;
30 | }
31 |
32 | export function AssignedUserAction({
33 | userId,
34 | projectId
35 | }: AssignedUserActionProps) {
36 | const router = useRouter();
37 | const [isOpen, setIsOpen] = useState(false);
38 | const [isPending, startTransition] = useTransition();
39 |
40 | const onChange = (open: boolean) => {
41 | if (!open) {
42 | setIsOpen(false);
43 | }
44 | };
45 |
46 | const onRemove = () => {
47 | startTransition(() => {
48 | unassignUser(userId, projectId)
49 | .then((data) => {
50 | if (data.error) {
51 | toast.error(data.error);
52 | }
53 |
54 | if (data.success) {
55 | setIsOpen(false);
56 | toast.success(data.success);
57 | router.refresh();
58 | }
59 | })
60 | .catch(() => toast.error('Uh oh! Something went wrong.'));
61 | });
62 | };
63 |
64 | return (
65 | <>
66 |
67 |
68 |
73 |
74 |
75 |
76 |
77 | setIsOpen(true)}>
78 |
82 | Remove
83 |
84 |
85 |
86 |
87 |
88 |
89 | Are you sure?
90 |
91 | This action will remove this user from the project.
92 |
93 |
94 |
95 |
96 |
97 | Cancel
98 |
99 |
100 |
106 | {isPending && (
107 | <>
108 |
109 | Deleting...
110 | >
111 | )}
112 | {!isPending && <>Delete>}
113 |
114 |
115 |
116 |
117 | >
118 | );
119 | }
120 |
--------------------------------------------------------------------------------
/src/components/protected/projects/project-users.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useState } from 'react';
4 | import { UserIcon } from 'lucide-react';
5 | import { PlusIcon } from '@radix-ui/react-icons';
6 | import type { Prisma, User } from '@prisma/client';
7 |
8 | import { Button } from '@/components/ui/button';
9 | import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
10 | import { AssignUserDialog } from '@/components/protected/projects/assign-user-dialog';
11 | import { AssignedUserAction } from '@/components/protected/projects/assigned-user-action';
12 | import {
13 | Table,
14 | TableRow,
15 | TableBody,
16 | TableCell,
17 | TableHead,
18 | TableHeader
19 | } from '@/components/ui/table';
20 |
21 | type ProjectWithUsers = Prisma.ProjectGetPayload<{
22 | include: {
23 | users: true;
24 | };
25 | }>;
26 |
27 | interface ProjectUsersProps {
28 | allUsers: User[] | null;
29 | project: ProjectWithUsers | null;
30 | }
31 |
32 | export function ProjectUsers({ allUsers, project }: ProjectUsersProps) {
33 | const [isAssignOpen, setIsAssignOpen] = useState(false);
34 |
35 | const assignedUsers = allUsers?.filter((user) =>
36 | project?.users.some((u) => u.userId === user.id)
37 | );
38 | const unassignedUsers = allUsers?.filter(
39 | (user) => !project?.users.some((u) => u.userId === user.id)
40 | );
41 |
42 | return (
43 | <>
44 |
45 |
46 |
setIsAssignOpen(true)}
50 | >
51 |
52 | Assign User
53 |
54 |
55 |
56 |
57 |
58 |
59 | Name
60 | Email
61 |
62 |
63 |
64 | {assignedUsers?.length ? (
65 | assignedUsers.map((user) => (
66 |
67 |
68 |
69 |
70 |
74 |
75 |
76 |
77 |
78 |
{user.name}
79 |
80 |
81 | {user.email}
82 |
83 |
87 |
88 |
89 | ))
90 | ) : (
91 |
92 |
93 | No results.
94 |
95 |
96 | )}
97 |
98 |
99 |
100 |
101 | setIsAssignOpen(false)}
106 | />
107 | >
108 | );
109 | }
110 |
--------------------------------------------------------------------------------
/src/components/data-table/data-table-pagination.tsx:
--------------------------------------------------------------------------------
1 | import { type Table } from '@tanstack/react-table';
2 | import {
3 | ChevronLeftIcon,
4 | ChevronRightIcon,
5 | DoubleArrowLeftIcon,
6 | DoubleArrowRightIcon
7 | } from '@radix-ui/react-icons';
8 |
9 | import { Button } from '@/components/ui/button';
10 | import {
11 | Select,
12 | SelectItem,
13 | SelectValue,
14 | SelectTrigger,
15 | SelectContent
16 | } from '@/components/ui/select';
17 |
18 | interface DataTablePaginationProps {
19 | table: Table;
20 | pageSizeOptions?: number[];
21 | }
22 |
23 | export function DataTablePagination({
24 | table,
25 | pageSizeOptions = [10, 20, 30, 40, 50]
26 | }: DataTablePaginationProps) {
27 | return (
28 |
29 |
30 | {table.getFilteredSelectedRowModel().rows.length} of{' '}
31 | {table.getFilteredRowModel().rows.length} row(s) selected.
32 |
33 |
34 |
35 |
Rows per page
36 |
{
39 | table.setPageSize(Number(value));
40 | }}
41 | >
42 |
43 |
44 |
45 |
46 | {pageSizeOptions.map((pageSize) => (
47 |
48 | {pageSize}
49 |
50 | ))}
51 |
52 |
53 |
54 |
55 | Page {table.getState().pagination.pageIndex + 1} of{' '}
56 | {table.getPageCount()}
57 |
58 |
59 | table.setPageIndex(0)}
64 | disabled={!table.getCanPreviousPage()}
65 | >
66 |
67 |
68 | table.previousPage()}
74 | disabled={!table.getCanPreviousPage()}
75 | >
76 |
77 |
78 | table.nextPage()}
84 | disabled={!table.getCanNextPage()}
85 | >
86 |
87 |
88 | table.setPageIndex(table.getPageCount() - 1)}
94 | disabled={!table.getCanNextPage()}
95 | >
96 |
97 |
98 |
99 |
100 |
101 | );
102 | }
103 |
--------------------------------------------------------------------------------
/src/data/projects.ts:
--------------------------------------------------------------------------------
1 | import * as z from 'zod';
2 | import type { Project } from '@prisma/client';
3 | import { unstable_noStore as noStore } from 'next/cache';
4 |
5 | import { db } from '@/lib/db';
6 | import { ProjectFilterSchema } from '@/schemas';
7 |
8 | type ProjectWithCount = Project & {
9 | tasksCount: number;
10 | usersCount: number;
11 | };
12 |
13 | interface ReturnData {
14 | data: ProjectWithCount[];
15 | pageCount: number;
16 | }
17 |
18 | export async function getProjects(
19 | filters: z.infer
20 | ): Promise {
21 | noStore();
22 | const { page, per_page, sort, name, from, to } = filters;
23 |
24 | try {
25 | // Number of items per page
26 | const limit = per_page;
27 |
28 | // Number of items to skip
29 | const offset = (page - 1) * per_page;
30 |
31 | // Column and order to sort
32 | const [column, order] = (sort?.split('.').filter(Boolean) ?? [
33 | 'createdAt',
34 | 'desc'
35 | ]) as [keyof Project | undefined, 'asc' | 'desc' | undefined];
36 |
37 | // Convert the date strings to Date objects
38 | const fromDay = from ? new Date(from) : undefined;
39 | const toDay = to ? new Date(to) : undefined;
40 |
41 | // Define orderBy
42 | const orderBy: { [key: string]: 'asc' | 'desc' } =
43 | column && ['name', 'description', 'createdAt'].includes(column)
44 | ? order === 'asc'
45 | ? { [column]: 'asc' }
46 | : { [column]: 'desc' }
47 | : { createdAt: 'desc' };
48 |
49 | const [rawData, total] = await Promise.all([
50 | db.project.findMany({
51 | skip: offset,
52 | take: limit,
53 | include: {
54 | _count: {
55 | select: {
56 | tasks: true,
57 | users: true
58 | }
59 | }
60 | },
61 | where: {
62 | createdAt: {
63 | gte: fromDay,
64 | lte: toDay
65 | },
66 | OR:
67 | typeof name === 'string'
68 | ? [
69 | {
70 | name: {
71 | contains: name
72 | }
73 | },
74 | {
75 | description: {
76 | contains: name
77 | }
78 | }
79 | ]
80 | : undefined
81 | },
82 | orderBy
83 | }),
84 | db.project.count({
85 | where: {
86 | createdAt: {
87 | gte: fromDay,
88 | lte: toDay
89 | },
90 | OR:
91 | typeof name === 'string'
92 | ? [
93 | {
94 | name: {
95 | contains: name
96 | }
97 | },
98 | {
99 | description: {
100 | contains: name
101 | }
102 | }
103 | ]
104 | : undefined
105 | }
106 | })
107 | ]);
108 |
109 | const pageCount = Math.ceil(total / per_page);
110 |
111 | const data = rawData.map((project) => {
112 | const { _count, ...projectWithoutCount } = project;
113 | return {
114 | ...projectWithoutCount,
115 | tasksCount: _count.tasks,
116 | usersCount: _count.users
117 | };
118 | });
119 |
120 | return { data, pageCount };
121 | } catch {
122 | return { data: [], pageCount: 0 };
123 | }
124 | }
125 |
126 | export async function getProjectById(id: string) {
127 | try {
128 | const project = await db.project.findUnique({
129 | include: {
130 | users: true
131 | },
132 | where: {
133 | id
134 | }
135 | });
136 |
137 | return project;
138 | } catch {
139 | return null;
140 | }
141 | }
142 |
143 | export async function getAllProjects() {
144 | try {
145 | const projects = await db.project.findMany();
146 |
147 | return projects;
148 | } catch {
149 | return null;
150 | }
151 | }
152 |
--------------------------------------------------------------------------------
/src/components/data-table/data-table-toolbar.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import * as React from 'react';
4 | import type { Table } from '@tanstack/react-table';
5 | import { Cross2Icon, MagnifyingGlassIcon } from '@radix-ui/react-icons';
6 |
7 | import { cn } from '@/lib/utils';
8 | import { Input } from '@/components/ui/input';
9 | import { Button } from '@/components/ui/button';
10 | import type { DataTableFilterField } from '@/types';
11 | import { DataTableViewOptions } from '@/components/data-table/data-table-view-options';
12 | import { DataTableFacetedFilter } from '@/components/data-table/data-table-faceted-filter';
13 |
14 | interface DataTableToolbarProps
15 | extends React.HTMLAttributes {
16 | table: Table;
17 | filterFields?: DataTableFilterField[];
18 | }
19 |
20 | export function DataTableToolbar({
21 | table,
22 | filterFields = [],
23 | children,
24 | className,
25 | ...props
26 | }: DataTableToolbarProps) {
27 | const isFiltered = table.getState().columnFilters.length > 0;
28 |
29 | // Memoize computation of searchableColumns and filterableColumns
30 | const { searchableColumns, filterableColumns } = React.useMemo(() => {
31 | return {
32 | searchableColumns: filterFields.filter((field) => !field.options),
33 | filterableColumns: filterFields.filter((field) => field.options)
34 | };
35 | }, [filterFields]);
36 |
37 | return (
38 |
45 |
46 | {searchableColumns.length > 0 &&
47 | searchableColumns.map(
48 | (column) =>
49 | table.getColumn(column.value ? String(column.value) : '') && (
50 |
54 |
55 |
63 | table
64 | .getColumn(String(column.value))
65 | ?.setFilterValue(event.target.value)
66 | }
67 | className='h-8 w-[126px] lg:w-[222px] border-none shadow-none pl-0 focus-visible:ring-0'
68 | />
69 |
70 | )
71 | )}
72 | {filterableColumns.length > 0 &&
73 | filterableColumns.map(
74 | (column) =>
75 | table.getColumn(column.value ? String(column.value) : '') && (
76 |
84 | )
85 | )}
86 | {isFiltered && (
87 |
table.resetColumnFilters()}
92 | >
93 | Reset
94 |
95 |
96 | )}
97 |
98 |
99 | {children}
100 |
101 |
102 |
103 | );
104 | }
105 |
--------------------------------------------------------------------------------