본문 바로가기
Clone Coding

Jira-clone - Task 상세 페이지 및 Task 전용 페이지 구현

by zzuny-code 2025. 12. 8.
반응형

https://www.youtube.com/watch?v=37v63U7-iG0

 

5:03

 

1. Task 전용 페이지 생성

 

1-1. Tasks 페이지 라우트 생성

 

  • 워크스페이스의 모든 태스크를 보여주는 전용 페이지
  • 프로젝트 필터를 포함한 모든 필터 옵션 제공

 

src/app/(dashboard)/workspaces/[workspaceId]/tasks/page.tsx

import { getCurrent } from "@/features/auth/queries"
import { TaskViewSwitcher } from "@/features/tasks/component/task-view-switcher";
import { redirect } from "next/navigation";


const TasksPage = async () => {
    const user = await getCurrent();
    if(!user) redirect("/sign-in")

    return (
        <div className="h-full flex flex-col">
            <TaskViewSwitcher/>
        </div>
    )
}

export default TasksPage;

 

2. 프로젝트 필터 조건부 렌더링

2-1. TaskViewSwitcher Props 추가

src/features/tasks/component/task-view-switcher.tsx

interface TaskViewSwitcherProps{
    hideProjectFilter?:boolean // 프로젝트 필터 숨김 옵션
}

export const TaskViewSwitcher = ({hideProjectFilter}:TaskViewSwitcherProps) => {
	...
    <DataFilters hideProjectFilter={hideProjectFilter}/>
	...   
}

 

2-2. 프로젝트 상세 페이지에서 필터 숨김

src/app/(dashboard)/workspaces/[workspaceId]/projects/[projectId]/page.tsx

...
// 프로젝트 페이지에서는 프로젝트 필터 숨김 (이미 특정 프로젝트 내부이므로)
<TaskViewSwitcher hideProjectFilter/>

 

2-3. DataFilters에서 조건부 렌더링

 

  • Tasks 페이지: 모든 프로젝트의 태스크 표시 → 프로젝트 필터 필요
  • Project 페이지: 특정 프로젝트의 태스크만 표시 → 프로젝트 필터 불필요

 

src/features/tasks/component/data-filters.tsx

{!hideProjectFilter && (
    <Select
        defaultValue={projectId ?? undefined}
        onValueChange={(value)=> onProjectChange(value)}
    >
        <SelectTrigger className="w-full lg:w-auto h-8">
            <div className="flex items-center pr-2">
                <FolderIcon className="size-4 mr-2"/>
                <SelectValue placeholder="All projects"/>
            </div>
        </SelectTrigger>
        <SelectContent className="bg-white">
            <SelectItem value="al">All projects</SelectItem>
            <SelectSeparator/>
            {projectOptions?.map((project)=>(
                <SelectItem key={project.value} value={project.value}>
                    {project.label}
                </SelectItem>
            ))}
        </SelectContent>
    </Select>
)}

 

 

3. 공통 UI 컴포넌트 생성

 

  • 데이터 로딩 중 / 에러 발생 시 사용자에게 피드백 제공
  • 일관된 UX 유지

 

3-1. PageLoader 컴포넌트

src/components/page-loader.tsx

import { Loader } from "lucide-react"

export const PageLoader = () => {
    return(
        <div className="flex items-center justify-center h-screen">
            <Loader className="size-6 animate-spin text-muted-foreground"/>
        </div>
    )
}

 

3-2. PageError 컴포넌트

src/components/page-error.tsx

import { AlertTriangle } from "lucide-react";

interface PageErrorProps{
    message: string;
}

export const PageError = ({
    message = "Something went wrong"
}:PageErrorProps) => {
    return(
        <div className="flex flex-col items-center justify-center h-full">
            <AlertTriangle className="size-6 text-muted-foreground mb-2"/>
            <p className="text-sm font-medium text-muted-foreground">{message}</p>
        </div>
    )
}

 

 

 

4. Task 상세 페이지 구현

 

  • URL 파라미터에서 taskId 추출
  • 다른 컴포넌트에서 재사용 가능

구조 

 

  • Server Component: 인증 확인
  • Client Component: 데이터 페칭 및 UI 렌더링

 

 

 

4-1. useTaskId 커스텀 훅

src/features/tasks/hooks/use-task-id.ts

import { useParams } from "next/navigation"

export const useTaskId = () => {
    const params = useParams();
    return params.taskId as string
}

 

 

4-2. TaskIdClient 컴포넌트

src/app/(dashboard)/workspaces/[workspaceId]/tasks/[taskId]/client.tsx

"use client"

import { PageError } from "@/components/page-error";
import { PageLoader } from "@/components/page-loader";
import { useGetTask } from "@/features/tasks/api/use-get-task";
import { useTaskId } from "@/features/tasks/hooks/use-task-id"

export const TaskIdClient = () => {
    const taskId = useTaskId();
    const {data, isLoading} = useGetTask({taskId});

    if(isLoading){
        return <PageLoader/>
    }

    if(!data){
        return <PageError message="Task not found"/>
    }

    return(
        <p>{JSON.stringify(data)}</p>
    )
}

 

 

4-3. TaskIdPage 서버 컴포넌트
src/app/(dashboard)/workspaces/[workspaceId]/tasks/[taskId]/page.tsx

import { getCurrent } from "@/features/auth/queries"
import { redirect } from "next/navigation";

import { TaskIdClient } from "./client"

const TaskIdPage = async() => {

    const user = await getCurrent();
    if(!user) redirect("/sign-in")

    return(
        <div>
            <TaskIdClient/>
        </div>
    )
}

export default TaskIdPage

 

 

 

5. Task Breadcrumbs (네비게이션 헤더)

src/features/tasks/component/task-breadcrumbs.tsx

import Link from "next/link";
import { ChevronRightIcon, TrashIcon } from "lucide-react";

import { Project } from "@/features/projects/type"
import { ProjectAvatar } from "@/features/projects/components/project-avatar";
import { useWorkspaceId } from "@/features/workspaces/hooks/use-workspace-id";
import { Button } from "@/components/ui/button";

import { Task } from "../types";

interface TaskBreadcrumbsProps{
    project: Project;
    task: Task
}

export const TaskBreadcrumbs = ({project, task}:TaskBreadcrumbsProps) => {
    
    const workspaceId = useWorkspaceId();

    return(
        <div className="flex items-center gap-x-2">
            {/* 프로젝트 아바타 */}
            <ProjectAvatar
                name={project.name}
                image={project.imageUrl}
                className="size-6 lg:size-8"
            />
            {/* 프로젝트 이름 (클릭 시 프로젝트 페이지로 이동) */}
            <Link href={`/workspaces/${workspaceId}/projects/${project.$id}`}>
                <p className="text-sm lg:text-lg font-semibold text-muted-foreground hover:opacity-75 transition">
                    {project.name}
                </p>            
            </Link>
            {/* 구분자 */}
            <ChevronRightIcon className="size-4 lg:size-5 text-muted-foreground"/>
            {/* 태스크 이름 */}
            <p className="text-sm lg:text-lg font-semibold">
                {task.name}
            </p>
            {/* 삭제 버튼 */}
            <Button
                className="ml-auto"
                variant="destructive"
                size="sm"
            >
                <TrashIcon className="size-4 lg:mr-2"/>
                <span className="hidden lg:block">Delete Task</span>
            </Button>
        </div>
    )
}

 

TaskIdClient에 Breadcrumbs 추가

src/app/(dashboard)/workspaces/[workspaceId]/tasks/[taskId]/client.tsx

export const TaskIdClient = () => {
    const taskId = useTaskId();
    const {data, isLoading} = useGetTask({taskId});

    if(isLoading) return <PageLoader/>
    if(!data) return <PageError message="Task not found"/>

    return(
        <div className="flex flex-col">
            <TaskBreadcrumbs project={data.project} task={data}/>
        </div>
    )
}



 

6. 삭제 기능 구현

 

  • 삭제 버튼 클릭
  • 확인 다이얼로그 표시
  • 확인 시 API 호출
  • 성공 시 Tasks 페이지로 리다이렉트

 

src/features/tasks/component/task-breadcrumbs.tsx

...

    const router = useRouter();

    const {mutate} = useDeleteTask();
    const [confirmDialog, confirm] = useConfirm(
        "Delete task",
        "This action cannot be undone.",
        "destructive"
    )

    const handleDeleteTask = async () => {
        const ok = await confirm();
        if(!ok) return;

        mutate({param: {taskId: task.$id}}, {
            onSuccess : () => {
                router.push(`/workspaces/${workspaceId}/tasks`)
            }
        })
    }

    return(
        <div className="flex items-center gap-x-2">
        	<ConfirmDialog/>
            ...
            <Button
                onClick={handleDeleteTask}
                disabled={isPending}
                className="ml-auto"
                variant="destructive"
                size="sm"
            >
                <TrashIcon className="size-4 lg:mr-2"/>
                <span className="hidden lg:block">Delete Task</span>
            </Button>
        </div>
    )
}

 

 

7. Task Overview (개요) 섹션

7-1. OverviewProperty 컴포넌트

 

  • 레이블-값 쌍을 일관된 형식으로 표시
  • 재사용 가능한 레이아웃 컴포넌트

 

src/features/tasks/component/overview-property.tsx

interface OverviewPropertyProps{
    label : string;
    children: React.ReactNode
}

export const OverviewProperty = ({
    label,
    children
}:OverviewPropertyProps) =>{
    return(
        <div className="flex items-start gap-x-2">
            {/* 레이블 */}
            <div className="min-w-[100px]">
                <p className="text-sm text-muted-foreground">
                    {label}
                </p>
            </div>
            {/* 값 */}
            <div className="flex items-center gap-x-2">
                {children}
            </div>
        </div>
    )
}

 

 

7-2. TaskOverview 컴포넌트

src/features/tasks/component/task-overview.tsx

import { PencilIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import DottedSeparator from "@/components/dottedSeparator";
import { OverviewProperty } from "./overview-property";
import { MemberAvatar } from "@/features/members/components/members-avatar";
import { Task } from "../types"
import { TaskDate } from "./task-date";
import { Badge } from "@/components/ui/badge";
import { snakeCaseToTitleCase } from "@/lib/utils";
import { useEditTaskModal } from "../hooks/use-edit-task-modal";

interface TaskOverviewProps {
    task: Task;
}

export const TaskOverview = ({task}:TaskOverviewProps)=>{

    const {open} = useEditTaskModal();

    return(
        <div className="flex flex-col gap-y-4 col-span-1">
            <div className="bg-muted rounded-lg p-4">
                {/* 헤더 */}
                <div className="flex items-center justify-between">
                    <p className="text-lg font-semibold">Overview</p>
                    <Button onClick={()=>open(task.$id)} size="sm" variant="secondary">
                        <PencilIcon className="size-4 mr-2"/>
                        Edit
                    </Button>
                </div>
                <DottedSeparator className="my-4"/>
                
                {/* 태스크 정보 */}
                <div className="flex flex-col gap-y-4">
                    {/* 담당자 */}
                    <OverviewProperty label="Assignee">
                        <MemberAvatar
                            name={task.assignee.name}
                            className="size-6"
                        />
                        <p className="text-sm font-medium">{task.assignee.name}</p>
                    </OverviewProperty>
                    
                    {/* 마감일 */}
                    <OverviewProperty label="Due Date">
                        <TaskDate value={task.dueDate} className="text-sm font-medium"/>
                    </OverviewProperty>
                    
                    {/* 상태 */}
                    <OverviewProperty label="Status">
                        <Badge variant={task.status}>
                            {snakeCaseToTitleCase(task.status)}
                        </Badge>
                    </OverviewProperty>
                </div>
            </div>
        </div>
    )
}

 

TaskIdClient에 Overview 추가

src/app/(dashboard)/workspaces/[workspaceId]/tasks/[taskId]/client.tsx

export const TaskIdClient = () => {
    // ...
    return(
        <div className="flex flex-col">
            <TaskBreadcrumbs project={data.project} task={data}/>
            <DottedSeparator className="my-6"/>
            <div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
                <TaskOverview task={data}/>
            </div>
        </div>
    )
}

 

* Edit 버튼 클릭 시 모달이 열리지만 아직 닫히지 않는 상태 (추후 수정 필요)

 

 

8. Task Description (설명) 섹션

 

  • 읽기 모드: 설명 표시 (없으면 "No description set")
  • 편집 모드: Textarea로 설명 수정 가능
  • 상태 관리: isEditing으로 모드 전환
  • 저장: API 호출로 변경사항 저장

 

 

8-1. Textarea 컴포넌트 설치

bunx --bun shadcn@2.1.0 add textarea

 

8-2. TaskDescription 컴포넌트

src/features/tasks/component/task-description.tsx

import { Button } from "@/components/ui/button";
import { Task } from "../types"
import { PencilIcon, XIcon } from "lucide-react";
import DottedSeparator from "@/components/dottedSeparator";
import { useState } from "react";
import { useUpdateTask } from "../api/use-update-task";
import { Textarea } from "@/components/ui/textarea";

interface TaskDescriptionProps{
    task:Task;
}

export const TaskDescription = ({task}:TaskDescriptionProps) => {

    const [isEditing, setIsEditing] = useState(false);
    const [value, setValue] = useState(task.description);
    const {mutate, isPending} = useUpdateTask();

    const handleSave = () => {
        mutate({
            json:{description: value},
            param: {taskId:task.$id}
        },{
            onSuccess:()=>{
                setIsEditing(false)
            }
        })
    }

    return(
        <div className="p-4 border rounded-lg">
            {/* 헤더 */}
            <div className="flex items-center justify-between">
                <p className="text-lg font-semibold">Overview</p>
                <Button 
                    onClick={()=>setIsEditing((prev)=> !prev)} 
                    size="sm" 
                    variant="secondary"
                >
                    {isEditing ? (
                        <XIcon className="size-4 mr-2"/>
                    ) : (
                        <PencilIcon className="size-4 mr-2"/>
                    )}                    
                    {isEditing ? "Cancel" : "Edit"}
                </Button>
            </div>
            <DottedSeparator className="my-4"/>
            
            {/* 편집 모드 */}
            {isEditing ? (
                <div className="flex flex-col gap-y-4">
                    <Textarea
                        placeholder="Add a description..."
                        value={value}
                        rows={4}
                        onChange={(e)=>setValue(e.target.value)}
                        disabled={isPending}
                    />
                    <Button
                        size="sm"
                        className="w-fit ml-auto"
                        onClick={handleSave}
                        disabled={isPending}
                    >
                        {isPending ? "Saving..." : "Save Changes"}
                    </Button>
                </div>
            ) : (
                /* 읽기 모드 */
                <div className="flex flex-col gap-y-4">
                    <div>
                        {task.description || (
                            <span className="text-muted-foreground">
                                No description set
                            </span>
                        )}
                    </div>
                </div>
            )}
        </div>
    )
}

 

TaskIdClient에 Description 추가

src/app/(dashboard)/workspaces/[workspaceId]/tasks/[taskId]/client.tsx

...
<TaskDescription task={data}/>

 

 

반응형