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}/>
'Clone Coding' 카테고리의 다른 글
| Jira-clone - Project Analytics 구현 정리 (0) | 2025.12.10 |
|---|---|
| Jira-clone - Server Component를 Client Component로 리팩토링 (0) | 2025.12.10 |
| Jira-clone - React Big Calendar를 활용한 태스크 캘린더 (0) | 2025.12.01 |
| Jira-clone - 칸반 보드 드래그앤드롭 (0) | 2025.11.27 |
| Jira-clone - 칸반 보드 구현 (0) | 2025.11.26 |