https://www.youtube.com/watch?v=37v63U7-iG0&t=4s
1. useCreateTaskModal Hook 생성
src/features/tasks/hooks/use-create-task-modal.ts
import {useQueryState, parseAsBoolean} from "nuqs"
export const useCreateTaskModal = () => {
const [isOpen, setIsOpen] = useQueryState(
"create-task",
parseAsBoolean.withDefault(false).withOptions({clearOnDefault:true})
)
const open = () => setIsOpen(true)
const close = () => setIsOpen(false)
return{
isOpen,
open,
close,
setIsOpen,
}
}
2. CreateTaskModal 컴포넌트 생성 (초기 버전)
src/features/tasks/component/create-task-modal.tsx
'use client'
import { ResponsiveModal } from "@/components/responsive-modal";
import { useCreateTaskModal } from "../hooks/use-create-task-modal"
export const CreateTaskModal = () => {
const {isOpen, setIsOpen} = useCreateTaskModal();
return(
<ResponsiveModal open={isOpen} onOpenChange={setIsOpen}>
<div>
TODO: task form
</div>
</ResponsiveModal>
)
}
3. Dashboard 레이아웃에 CreateTaskModal 추가
src/app/(dashboard)/layout.tsx
import { CreateTaskModal } from "@/features/tasks/component/create-task-modal"
const DashboardLayout = ({children}:DashboardLayoutProps) => {
return(
<div className='min-h-screen'>
<CreateTaskModal/>
{/* ...기존 코드... */}
</div>
)
}
export default DashboardLayout
4. TaskViewSwitcher에 New 버튼 추가
src/features/tasks/component/task-view-switcher.tsx
import { PlusIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import { useCreateTaskModal } from "../hooks/use-create-task-modal"
export const TaskViewSwitcher = () => {
const {open} = useCreateTaskModal();
return(
<div>
<Button
onClick={open}
size="sm"
className="w-full lg:w-auto"
>
<PlusIcon className="size-4 mr-2"/>
New
</Button>
</div>
)
}

5. CreateTaskFormWrapper 컴포넌트 생성 (초기 버전)
src/features/tasks/component/create-task-form-wrapper.tsx
import { Card, CardContent } from "@/components/ui/card";
import { useGetMembers } from "@/features/members/api/use-get-members";
import { useGetProjects } from "@/features/projects/api/use-get-projects";
import { useWorkspaceId } from "@/features/workspaces/hooks/use-workspace-id"
import { Loader } from "lucide-react";
import { CreateTaskForm } from "./create-task-form";
interface CreateTaskFormWrapperProps{
onCancel: () => void
}
export const CreateTaskFormWrapper = ({
onCancel,
}: CreateTaskFormWrapperProps) => {
const workspaceId = useWorkspaceId();
const {data: projects, isLoading: isLoadingProjects} = useGetProjects({workspaceId})
const {data: members, isLoading: isLoadingMembers} = useGetMembers({workspaceId})
const projectOptions = projects?.documents.map((project) => ({
id: project.$id,
name: project.name,
imageUrl: project.imageUrl,
}))
const memberOptions = members?.documents.map((member) => ({
id: member.$id,
name: member.name,
}))
const isLoading = isLoadingProjects || isLoadingMembers
if(isLoading){
return (
<Card className="w-full h-[714px] border-none shadow-none">
<CardContent className="flex items-center justify-center h-full">
<Loader className="size-5 animate-spin text-muted-foreground"/>
</CardContent>
</Card>
)
}
return(
<div>
<CreateTaskForm
onCancel={onCancel}
projectOptions={projectOptions ?? []}
memberOptions={memberOptions ?? []}
/>
</div>
)
}
6. CreateTaskModal 업데이트
src/features/tasks/component/create-task-modal.tsx
'use client'
import { ResponsiveModal } from "@/components/responsive-modal";
import { useCreateTaskModal } from "../hooks/use-create-task-modal"
import { CreateTaskFormWrapper } from "./create-task-form-wrapper";
export const CreateTaskModal = () => {
const {isOpen, setIsOpen, close} = useCreateTaskModal();
return(
<ResponsiveModal open={isOpen} onOpenChange={setIsOpen}>
<CreateTaskFormWrapper onCancel={close}/>
</ResponsiveModal>
)
}

7. useCreateTask Hook 생성
src/features/tasks/api/use-create-task.ts
import { toast } from 'sonner';
import {useMutation, useQueryClient} from '@tanstack/react-query'
import { InferRequestType, InferResponseType } from 'hono';
import {client} from '@/lib/rpc'
type ResponseType = InferResponseType<typeof client.api.tasks['$post'], 200>
type RequestType = InferRequestType<typeof client.api.tasks['$post']>
export const useCreateTask = () => {
const queryClient = useQueryClient()
const mutation = useMutation<
ResponseType,
Error,
RequestType
>({
mutationFn: async({json}) => {
const response = await client.api.tasks["$post"]({json});
if(!response.ok){
throw new Error("Failed to create task")
}
return response.json()
},
onSuccess: () => {
toast.success('task created')
queryClient.invalidateQueries({queryKey : ['tasks']})
},
onError: ()=> {
toast.error("Failed to create task")
}
})
return mutation
}
8. useGetTasks Hook 생성
src/features/tasks/api/use-get-task.ts
import {useQuery} from "@tanstack/react-query"
import {client} from "@/lib/rpc"
interface useGetTasksProps{
workspaceId: string;
}
export const useGetTasks = ({workspaceId}:useGetTasksProps) => {
const query = useQuery({
queryKey:["tasks", workspaceId],
queryFn: async () => {
const response = await client.api.tasks.$get({
query: {workspaceId}
});
if(!response.ok){
throw new Error("Failted to fetch task")
}
const {data} = await response.json();
return data;
}
})
return query
}
9. CreateTaskForm 컴포넌트 생성 (초기 버전)
src/features/tasks/component/create-task-form.tsx
'use client'
import z from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useWorkspaceId } from "@/features/workspaces/hooks/use-workspace-id";
import { cn } from "@/lib/utils";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import DottedSeparator from "@/components/dottedSeparator";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { createTaskSchema } from "../schemas";
import { useCreateTask } from "../api/use-create-task";
interface CreateTaskFormProps{
onCancel?: () => void;
projectOptions: {id:string, name: string, imageUrl: string}[];
memberOptions: {id:string, name: string}[];
}
export const CreateTaskForm = ({onCancel, projectOptions, memberOptions} : CreateTaskFormProps) =>{
const workspaceId = useWorkspaceId()
// const router = useRouter();
const {mutate, isPending} = useCreateTask();
const form = useForm<z.infer<typeof createTaskSchema>>({
resolver: zodResolver(createTaskSchema.omit({workspaceId: true})),
defaultValues:{
workspaceId,
}
})
const onSubmit = (values: z.infer<typeof createTaskSchema>) => {
mutate({json:{...values, workspaceId}}, {
onSuccess:()=>{
form.reset();
// TODO: Redirect to new task
}
})
}
return(
<Card className="w-full h-full border-none shadow-none">
<CardHeader className="flex p-7">
<CardTitle className="text-xl font-bold">
Create a new Task
</CardTitle>
</CardHeader>
<div className="px=7">
<DottedSeparator/>
</div>
<CardContent className="p-7">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<div className="flex flex-col gap-y-4">
<FormField
control={form.control}
name="name"
render={({field})=>(
<FormItem>
<FormLabel>Task Name</FormLabel>
<FormControl>
<Input
{...field}
placeholder="Enter task name"
/>
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
<FormField
control={form.control}
name="dueDate"
render={({field})=>(
<FormItem>
<FormLabel>Due Date</FormLabel>
<FormControl>
{/* TODO : Date Picker */}
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
</div>
<DottedSeparator className="py-7"/>
<div className="flex items-center justify-between">
<Button
type="button"
size="lg"
variant='secondary'
onClick={onCancel}
disabled={isPending}
className={cn(!onCancel && "invisible")}
// onCancel prop이 전달되지 않으면 버튼을 invisible 처리하여 레이아웃은 유지하되 시각적으로 숨김
>
Cancel
</Button>
<Button
type="submit"
size="lg"
disabled={isPending}
>
Create Task
</Button>
</div>
</form>
</Form>
</CardContent>
</Card>
)
}
10. CreateTaskFormWrapper 업데이트
src/features/tasks/component/create-task-form-wrapper.tsx
import { Card, CardContent } from "@/components/ui/card";
import { useGetMembers } from "@/features/members/api/use-get-members";
import { useGetProjects } from "@/features/projects/api/use-get-projects";
import { useWorkspaceId } from "@/features/workspaces/hooks/use-workspace-id"
import { Loader } from "lucide-react";
import { CreateTaskForm } from "./create-task-form";
interface CreateTaskFormWrapperProps{
onCancel: () => void
}
export const CreateTaskFormWrapper = ({
onCancel,
}: CreateTaskFormWrapperProps) => {
const workspaceId = useWorkspaceId();
const {data: projects, isLoading: isLoadingProjects} = useGetProjects({workspaceId})
const {data: members, isLoading: isLoadingMembers} = useGetMembers({workspaceId})
const projectOptions = projects?.documents.map((project) => ({
id: project.$id,
name: project.name,
imageUrl: project.imageUrl,
}))
const memberOptions = members?.documents.map((project) => ({
id: project.$id,
name: project.name,
}))
const isLoading = isLoadingProjects || isLoadingMembers
if(isLoading){
return (
<Card className="w-full h-[714px] border-none shadow-none">
<CardContent className="flex items-center justify-center h-full">
<Loader className="size-5 animate-spin text-muted-foreground"/>
</CardContent>
</Card>
)
}
return(
<div>
<CreateTaskForm
onCancel={onCancel}
projectOptions={projectOptions ?? []}
memberOptions={memberOptions ?? []}
/>
</div>
)
}
11. date-fns 설치
bun add date-fns
// bun add date-fns@4.1.0
12. DatePicker 컴포넌트 생성
src/components/date-picker.tsx
import * as React from 'react'
import {format} from 'date-fns'
import { Calendar as CalendarIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "./ui/button";
import { Calendar } from "./ui/calendar";
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
interface DatePickerProps{
value: Date | undefined;
onChange: (date:Date) => void;
className?: string;
placeholder?: string;
}
export const DatePicker = ({value, onChange, className, placeholder="Select date"}:DatePickerProps) => {
return(
<Popover>
<PopoverTrigger asChild>
<Button
variant='outline'
size='lg'
className={cn(
"w-full justify-start text-left font-normal px-3",
!value && "text-muted-foreground",
className
)}
>
<CalendarIcon className="mr-2 h-4 w-4"/>
{value ? format(value, "PPP") : <span>{placeholder}</span>}
</Button>
</PopoverTrigger>
<PopoverContent className='w-auto p-0'>
<Calendar
mode='single'
selected={value}
onSelect={(date)=> onChange(date as Date)}
autoFocus
/>
</PopoverContent>
</Popover>
)
}
13. CreateTaskForm 완성 버전
src/features/tasks/component/create-task-form.tsx 적용하고 나머지 form들도 추가해줌
'use client'
import z from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useWorkspaceId } from "@/features/workspaces/hooks/use-workspace-id";
import { cn } from "@/lib/utils";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import DottedSeparator from "@/components/dottedSeparator";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { TaskStatus } from "../types";
import { createTaskSchema } from "../schemas";
import { useCreateTask } from "../api/use-create-task";
import { DatePicker } from "@/components/date-picker";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { MemberAvatar } from "@/features/members/components/members-avatar";
import { ProjectAvatar } from "@/features/projects/components/project-avatar";
interface CreateTaskFormProps{
onCancel?: () => void;
projectOptions: {id:string, name: string, imageUrl: string}[];
memberOptions: {id:string, name: string}[];
}
export const CreateTaskForm = ({onCancel, projectOptions, memberOptions} : CreateTaskFormProps) =>{
const workspaceId = useWorkspaceId()
// const router = useRouter();
const {mutate, isPending} = useCreateTask();
const form = useForm<z.infer<typeof createTaskSchema>>({
resolver: zodResolver(createTaskSchema.omit({workspaceId: true})),
defaultValues:{
workspaceId,
}
})
const onSubmit = (values: z.infer<typeof createTaskSchema>) => {
mutate({json:{...values, workspaceId}}, {
onSuccess:()=>{
form.reset();
onCancel?.()
}
})
}
return(
<Card className="w-full h-full border-none shadow-none">
<CardHeader className="flex p-7">
<CardTitle className="text-xl font-bold">
Create a new Task
</CardTitle>
</CardHeader>
<div className="px=7">
<DottedSeparator/>
</div>
<CardContent className="p-7">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<div className="flex flex-col gap-y-4">
<FormField
control={form.control}
name="name"
render={({field})=>(
<FormItem>
<FormLabel>Task Name</FormLabel>
<FormControl>
<Input
{...field}
placeholder="Enter Project name"
/>
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
<FormField
control={form.control}
name="dueDate"
render={({field})=>(
<FormItem>
<FormLabel>Due Date</FormLabel>
<FormControl>
<DatePicker {...field}/>
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
<FormField
control={form.control}
name="assigneeId"
render={({field})=>(
<FormItem>
<FormLabel>Assignee</FormLabel>
<Select
defaultValue={field.value}
onValueChange={field.onChange}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select assignee"/>
</SelectTrigger>
</FormControl>
<FormMessage/>
<SelectContent>
{memberOptions.map((member) => (
<SelectItem key={member.id} value={member.id}>
<div className="flex items-center gap-x-2">
<MemberAvatar
className="size-6"
name={member.name}
/>
{member.name}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</FormItem>
)}
/>
<FormField
control={form.control}
name="status"
render={({field})=>(
<FormItem>
<FormLabel>Status</FormLabel>
<Select
defaultValue={field.value}
onValueChange={field.onChange}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select status"/>
</SelectTrigger>
</FormControl>
<FormMessage/>
<SelectContent>
<SelectItem value={TaskStatus.BACKLOT}>
Backlog
</SelectItem>
<SelectItem value={TaskStatus.IN_PROGRESS}>
In Progress
</SelectItem>
<SelectItem value={TaskStatus.IN_PREVIEW}>
In Preview
</SelectItem>
<SelectItem value={TaskStatus.TODO}>
Todo
</SelectItem>
<SelectItem value={TaskStatus.DONE}>
Done
</SelectItem>
</SelectContent>
</Select>
</FormItem>
)}
/>
<FormField
control={form.control}
name="projectId"
render={({field})=>(
<FormItem>
<FormLabel>Project</FormLabel>
<Select
defaultValue={field.value}
onValueChange={field.onChange}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select Project"/>
</SelectTrigger>
</FormControl>
<FormMessage/>
<SelectContent>
{projectOptions.map((project) => (
<SelectItem key={project.id} value={project.id}>
<div className="flex items-center gap-x-2">
<ProjectAvatar
className="size-6"
name={project.name}
image={project.imageUrl}
/>
{project.name}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</FormItem>
)}
/>
</div>
<DottedSeparator className="py-7"/>
<div className="flex items-center justify-between">
<Button
type="button"
size="lg"
variant='secondary'
onClick={onCancel}
disabled={isPending}
className={cn(!onCancel && "invisible")}
>
Cancel
</Button>
<Button
type="submit"
size="lg"
disabled={isPending}
>
Create Task
</Button>
</div>
</form>
</Form>
</CardContent>
</Card>
)
}
14. Appwrite Tasks 테이블 권한 설정
appwrite -> tasks -> setting -> Permissions -> users 에 권한 다주기

15. Task 생성 테스트
"Create New Task" 버튼 클릭 → 폼 작성 → 제출 → Appwrite에서 데이터 확인

'Clone Coding' 카테고리의 다른 글
| Jira-clone - TanStack Table로 작업 관리 테이블 만들기 (0) | 2025.11.19 |
|---|---|
| Jira-clone - Task View Switcher와 Data Filters 구현 정리 (0) | 2025.11.13 |
| Jira-clone - Tasks 기능 구현 (0) | 2025.11.05 |
| Jira-clone - Project ID 페이지 구현 및 에러/로딩 처리 정리 (0) | 2025.11.05 |
| Jira-clone - Projects 기능 구현 (0) | 2025.10.29 |