diff --git a/apps/app/package.json b/apps/app/package.json index 06f65ef17..a3f25721d 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -48,7 +48,7 @@ "@trigger.dev/sdk": "4", "@trycompai/db": "^1.3.4", "@types/canvas-confetti": "^1.9.0", - "@types/three": "^0.177.0", + "@types/three": "^0.180.0", "@uploadthing/react": "^7.3.0", "@upstash/ratelimit": "^2.0.5", "@vercel/sdk": "^1.7.1", diff --git a/apps/app/src/actions/tasks/create-task-action.ts b/apps/app/src/actions/tasks/create-task-action.ts new file mode 100644 index 000000000..b04552813 --- /dev/null +++ b/apps/app/src/actions/tasks/create-task-action.ts @@ -0,0 +1,79 @@ +'use server'; + +import { authActionClient } from '@/actions/safe-action'; +import { db, Departments, TaskFrequency } from '@db'; +import { revalidatePath } from 'next/cache'; +import { headers } from 'next/headers'; +import { z } from 'zod'; + +const createTaskSchema = z.object({ + title: z.string().min(1, { + message: 'Title is required', + }), + description: z.string().min(1, { + message: 'Description is required', + }), + assigneeId: z.string().nullable().optional(), + frequency: z.nativeEnum(TaskFrequency).nullable().optional(), + department: z.nativeEnum(Departments).nullable().optional(), + controlIds: z.array(z.string()).optional(), +}); + +export const createTaskAction = authActionClient + .inputSchema(createTaskSchema) + .metadata({ + name: 'create-task', + track: { + event: 'create-task', + channel: 'server', + }, + }) + .action(async ({ parsedInput, ctx }) => { + const { title, description, assigneeId, frequency, department, controlIds } = parsedInput; + const { + session: { activeOrganizationId }, + user, + } = ctx; + + if (!user.id || !activeOrganizationId) { + throw new Error('Invalid user input'); + } + + try { + const task = await db.task.create({ + data: { + title, + description, + assigneeId: assigneeId || null, + organizationId: activeOrganizationId, + status: 'todo', + order: 0, + frequency: frequency || null, + department: department || null, + ...(controlIds && + controlIds.length > 0 && { + controls: { + connect: controlIds.map((id) => ({ id })), + }, + }), + }, + }); + + // Revalidate the path based on the header + const headersList = await headers(); + let path = headersList.get('x-pathname') || headersList.get('referer') || ''; + path = path.replace(/\/[a-z]{2}\//, '/'); + revalidatePath(path); + + return { + success: true, + task, + }; + } catch (error) { + console.error('Failed to create task:', error); + return { + success: false, + error: 'Failed to create task', + }; + } + }); diff --git a/apps/app/src/app/(app)/[orgId]/tasks/components/CreateTaskSheet.tsx b/apps/app/src/app/(app)/[orgId]/tasks/components/CreateTaskSheet.tsx new file mode 100644 index 000000000..7d0a1b5fe --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/tasks/components/CreateTaskSheet.tsx @@ -0,0 +1,328 @@ +'use client'; + +import { createTaskAction } from '@/actions/tasks/create-task-action'; +import { SelectAssignee } from '@/components/SelectAssignee'; +import { Button } from '@comp/ui/button'; +import { Drawer, DrawerContent, DrawerTitle } from '@comp/ui/drawer'; +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@comp/ui/form'; +import { useMediaQuery } from '@comp/ui/hooks'; +import { Input } from '@comp/ui/input'; +import MultipleSelector, { Option } from '@comp/ui/multiple-selector'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@comp/ui/select'; +import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@comp/ui/sheet'; +import { Textarea } from '@comp/ui/textarea'; +import { Departments, Member, TaskFrequency, User } from '@db'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { ArrowRightIcon, X } from 'lucide-react'; +import { useAction } from 'next-safe-action/hooks'; +import { useQueryState } from 'nuqs'; +import { useCallback, useMemo } from 'react'; +import { useForm } from 'react-hook-form'; +import { toast } from 'sonner'; +import { z } from 'zod'; +import { taskDepartments, taskFrequencies } from '../[taskId]/components/constants'; + +const createTaskSchema = z.object({ + title: z.string().min(1, { + message: 'Title is required', + }), + description: z.string().min(1, { + message: 'Description is required', + }), + assigneeId: z.string().nullable().optional(), + frequency: z.nativeEnum(TaskFrequency).nullable().optional(), + department: z.nativeEnum(Departments).nullable().optional(), + controlIds: z.array(z.string()).optional(), +}); + +export function CreateTaskSheet({ + members, + controls, +}: { + members: (Member & { user: User })[]; + controls: { id: string; name: string }[]; +}) { + const isDesktop = useMediaQuery('(min-width: 768px)'); + const [createTaskOpen, setCreateTaskOpen] = useQueryState('create-task'); + const isOpen = Boolean(createTaskOpen); + + const handleOpenChange = (open: boolean) => { + setCreateTaskOpen(open ? 'true' : null); + }; + + const createTask = useAction(createTaskAction, { + onSuccess: () => { + toast.success('Task created successfully'); + setCreateTaskOpen(null); + form.reset(); + }, + onError: (error) => { + toast.error(error.error?.serverError || 'Failed to create task'); + }, + }); + + const form = useForm>({ + resolver: zodResolver(createTaskSchema), + defaultValues: { + title: '', + description: '', + assigneeId: null, + frequency: null, + department: null, + controlIds: [], + }, + }); + + const onSubmit = useCallback( + (data: z.infer) => { + createTask.execute(data); + }, + [createTask], + ); + + // Memoize control options to prevent re-renders + const controlOptions = useMemo( + () => + controls.map((control) => ({ + value: control.id, + label: control.name, + })), + [controls], + ); + + // Memoize filter function to prevent re-renders + const filterFunction = useCallback( + (value: string, search: string) => { + // Find the option with this value (control ID) + const option = controlOptions.find((opt) => opt.value === value); + if (!option) return 0; + + // Check if the control name (label) contains the search string + return option.label.toLowerCase().includes(search.toLowerCase()) ? 1 : 0; + }, + [controlOptions], + ); + + // Memoize select handlers + const handleFrequencyChange = useCallback((value: string, onChange: (value: any) => void) => { + onChange(value === 'none' ? null : value); + }, []); + + const handleDepartmentChange = useCallback((value: string, onChange: (value: any) => void) => { + onChange(value === 'none' ? null : value); + }, []); + + const handleControlsChange = useCallback((options: Option[], onChange: (value: any) => void) => { + onChange(options.map((option) => option.value)); + }, []); + + const taskForm = ( +
+ + ( + + Task Title + + + + + + )} + /> + + ( + + Description + +