diff --git a/src/components/features/kanban/KanbanBoard.tsx b/src/components/features/kanban/KanbanBoard.tsx index 845a7a8..fd272a2 100644 --- a/src/components/features/kanban/KanbanBoard.tsx +++ b/src/components/features/kanban/KanbanBoard.tsx @@ -27,6 +27,7 @@ import KanbanLegend from "./components/KanbanLegend"; import KanbanFilterComponent, { KanbanFilterType, } from "./components/KanbanFilter"; +import SidePanel from "@/components/ui/SidePanel"; import { useKanbanKeyboard } from "@/hooks/kanban/useKanbanKeyboard"; interface KanbanBoardProps { @@ -64,20 +65,13 @@ const KanbanBoard = ({ const projectStartedAt = project?.started_at; const projectEndedAt = project?.ended_at; - console.log("KanbanBoard - Project Info:", { - project, - projectName, - projectStartedAt, - projectEndedAt, - boardId, - projectId, - }); - const [selectedTask, setSelectedTask] = useState(null); const [showTaskAddModal, setShowTaskAddModal] = useState(false); + const [addTaskDefaultStatus, setAddTaskDefaultStatus] = useState(undefined); const [activeTask, setActiveTask] = useState(null); const [showFilter, setShowFilter] = useState(false); const [showHelp, setShowHelp] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); const [filter, setFilter] = useState({ priority: "all", assignee: "all", @@ -120,21 +114,11 @@ const KanbanBoard = ({ return []; } - if (filter.assignee === "me") { - console.log("내 작업 필터 활성화"); - console.log("세션 상태:", status); - console.log("현재 사용자 ID:", session?.user?.user_id); - console.log( - "전체 태스크:", - tasks.map((t) => ({ - id: t.id, - title: t.title, - assigned_user_id: t.assigned_user_id, - })) - ); - } - return tasks.filter((task) => { + if (searchQuery && !task.title.toLowerCase().includes(searchQuery.toLowerCase())) { + return false; + } + if (filter.priority !== "all" && task.priority !== filter.priority) { return false; } @@ -150,11 +134,6 @@ const KanbanBoard = ({ const currentUserId = session?.user?.user_id; const taskUserId = task.assigned_user_id; - console.log( - `태스크 '${task.title}' 체크 - 할당된 사용자: ${taskUserId}, 현재 사용자: ${currentUserId}` - ); - console.log("타입 체크:", typeof taskUserId, typeof currentUserId); - const taskUserIdStr = taskUserId ? String(taskUserId) : null; const currentUserIdStr = currentUserId ? String(currentUserId) : null; @@ -230,7 +209,7 @@ const KanbanBoard = ({ return true; }); - }, [tasks, filter, session, status]); + }, [tasks, filter, session, status, searchQuery]); const groupedTasks = KANBAN_COLUMNS.reduce((acc, column) => { const columnTasks = filteredTasks.filter( @@ -302,19 +281,26 @@ const KanbanBoard = ({ }, []); const handleFilterChange = (key: keyof KanbanFilterType, value: string) => { - setFilter((prev) => ({ - ...prev, - [key]: value, - })); + setFilter((prev) => ({ ...prev, [key]: value })); + }; + + const handleFilterReset = () => { + setFilter({ priority: "all", assignee: "all", date: "all" }); }; + const handleColumnAddClick = useCallback((status: TaskStatus) => { + setAddTaskDefaultStatus(status); + setShowTaskAddModal(true); + }, []); + const handleTaskAddSuccess = useCallback( (taskData: Omit) => { onCreateTask?.(taskData); onTaskCreated?.(); setShowTaskAddModal(false); + setAddTaskDefaultStatus(undefined); }, - [onCreateTask, onTaskCreated, setShowTaskAddModal] + [onCreateTask, onTaskCreated] ); const handleUpdateTask = (taskId: string, updates: Partial) => { @@ -339,7 +325,7 @@ const KanbanBoard = ({ return ( {/* 전체 컨테이너 - 캘린더와 동일한 구조 */} -
+
{/* 칸반 헤더 */} setShowFilter(!showFilter)} onToggleHelp={() => setShowHelp(!showHelp)} showHelp={showHelp} + hasActiveFilter={ + filter.priority !== "all" || + filter.assignee !== "all" || + filter.date !== "all" + } + searchQuery={searchQuery} + onSearchChange={setSearchQuery} tasksCount={tasks.length} project={project} onProjectInfoClick={onProjectInfoClick} @@ -355,15 +348,19 @@ const KanbanBoard = ({ {/* 도움말 */} {showHelp && } + {/* 활성 필터 태그 칩 */} + + {/* 필터 */} {showFilter && ( -
+
)} @@ -380,6 +377,8 @@ const KanbanBoard = ({ groupedTasks={groupedTasks} projectId={projectId} onTaskClick={handleTaskClick} + onColumnAddClick={handleColumnAddClick} + onTitleUpdate={handleUpdateTask} isDragging={!!activeTask} /> @@ -406,8 +405,8 @@ const KanbanBoard = ({
- {selectedTask && ( - setSelectedTask(null)}> + setSelectedTask(null)}> + {selectedTask && ( setSelectedTask(null)} /> - - )} + )} + {showTaskAddModal && ( - setShowTaskAddModal(false)}> + { setShowTaskAddModal(false); setAddTaskDefaultStatus(undefined); }}> setShowTaskAddModal(false)} + onCancel={() => { setShowTaskAddModal(false); setAddTaskDefaultStatus(undefined); }} /> )} @@ -442,18 +442,22 @@ function ColumnGrid({ groupedTasks, projectId, onTaskClick, + onColumnAddClick, + onTitleUpdate, isDragging, }: { groupedTasks: Record; projectId: string; onTaskClick: (task: Task) => void; + onColumnAddClick: (status: TaskStatus) => void; + onTitleUpdate: (taskId: string, updates: Partial) => void; isDragging: boolean; }) { return (
-
+
-
+
{KANBAN_COLUMNS.map((column) => ( onColumnAddClick(column.id as TaskStatus)} + onTitleUpdate={(taskId, title) => onTitleUpdate(taskId, { title })} isDragging={isDragging} + collapsible={column.id === "done"} /> ))}
@@ -472,4 +479,57 @@ function ColumnGrid({ ); } +const FILTER_LABELS: Record> = { + priority: { high: "높음", normal: "보통", low: "낮음" }, + assignee: { me: "내 작업", assigned: "할당됨", unassigned: "미할당" }, + date: { today: "오늘", thisWeek: "이번주", overdue: "지연" }, +}; +const FILTER_NAMES: Record = { + priority: "우선순위", + assignee: "담당자", + date: "기간", +}; + +function FilterChips({ + filter, + onFilterChange, + onReset, +}: { + filter: KanbanFilterType; + onFilterChange: (key: keyof KanbanFilterType, value: string) => void; + onReset: () => void; +}) { + const chips = (Object.keys(filter) as (keyof KanbanFilterType)[]).filter( + (key) => filter[key] !== "all" + ); + + if (chips.length === 0) return null; + + return ( +
+ {chips.map((key) => ( + + {FILTER_NAMES[key]}: + {FILTER_LABELS[key]?.[filter[key]] ?? filter[key]} + + + ))} + +
+ ); +} + export default KanbanBoard; diff --git a/src/components/features/kanban/KanbanColumn.tsx b/src/components/features/kanban/KanbanColumn.tsx index 96bc7b2..6e0135c 100644 --- a/src/components/features/kanban/KanbanColumn.tsx +++ b/src/components/features/kanban/KanbanColumn.tsx @@ -1,21 +1,30 @@ +import { useState, useMemo } from "react"; import { useDroppable } from "@dnd-kit/core"; import { SortableContext, verticalListSortingStrategy, } from "@dnd-kit/sortable"; +import { Plus, ArrowUpDown, CalendarDays, Flag, ChevronDown, ChevronRight } from "lucide-react"; import { TaskCard } from "@/features/task"; import { Task, TaskStatus } from "@/types"; import { EmptyState } from "@/components/shared/EmptyState"; import { getTaskStatusColor, isTaskOverdue } from "@/lib/utils/taskUtils"; +type SortOrder = "default" | "deadline" | "priority"; + +const PRIORITY_RANK: Record = { high: 0, normal: 1, low: 2 }; + interface KanbanColumnProps { id: string; title: string; tasks: Task[]; projectId: string; onTaskClick: (task: Task) => void; - isDragging?: boolean; // 현재 드래그 중인지 + onAddClick?: () => void; + onTitleUpdate?: (taskId: string, title: string) => void; + isDragging?: boolean; + collapsible?: boolean; } const KanbanColumn = ({ @@ -24,68 +33,97 @@ const KanbanColumn = ({ tasks, projectId, onTaskClick, + onAddClick, + onTitleUpdate, isDragging = false, + collapsible = false, }: KanbanColumnProps) => { - const { setNodeRef, isOver } = useDroppable({ - id: id, - }); + const { setNodeRef, isOver } = useDroppable({ id }); + const [sortOrder, setSortOrder] = useState("default"); + const [collapsed, setCollapsed] = useState(false); - // 다크 모드는 CSS 클래스로 처리하도록 변경 const statusColors = getTaskStatusColor(id as TaskStatus); - // 지연된 태스크 개수 (완료 컬럼 제외) const overdueCount = id !== "done" ? tasks.filter((task) => isTaskOverdue(task)).length : 0; - // 드래그 오버 시 컬럼 스타일 + const sortedTasks = useMemo(() => { + if (sortOrder === "deadline") { + return [...tasks].sort((a, b) => { + if (!a.ended_at && !b.ended_at) return 0; + if (!a.ended_at) return 1; + if (!b.ended_at) return -1; + return a.ended_at.localeCompare(b.ended_at); + }); + } + if (sortOrder === "priority") { + return [...tasks].sort( + (a, b) => + (PRIORITY_RANK[a.priority ?? "low"] ?? 2) - + (PRIORITY_RANK[b.priority ?? "low"] ?? 2) + ); + } + return tasks; + }, [tasks, sortOrder]); + + const cycleSortOrder = () => { + setSortOrder((prev) => + prev === "default" ? "deadline" : prev === "deadline" ? "priority" : "default" + ); + }; + + const SortIcon = + sortOrder === "deadline" ? CalendarDays : sortOrder === "priority" ? Flag : ArrowUpDown; + const sortTitle = + sortOrder === "deadline" ? "마감일순" : sortOrder === "priority" ? "우선순위순" : "기본순"; + const getColumnStyle = () => { if (isOver) { - // 드롭 대상 컬럼 하이라이트 switch (id) { - case "todo": - return "ring-2 ring-gray-400 dark:ring-gray-500 bg-gray-100 dark:bg-gray-600"; - case "inprogress": - return "ring-2 ring-blue-400 dark:ring-blue-500 bg-blue-50 dark:bg-blue-900/30"; - case "done": - return "ring-2 ring-green-400 dark:ring-green-500 bg-green-50 dark:bg-green-900/30"; - default: - return ""; + case "todo": return "ring-2 ring-border bg-muted/60"; + case "inprogress": return "ring-2 ring-main-500 dark:ring-main-400 bg-main-500/5"; + case "done": return "ring-2 ring-emerald-500 dark:ring-emerald-400 bg-emerald-500/[0.063] dark:bg-emerald-900/20"; + default: return ""; } } - if (isDragging) { - // 드래그 중일 때 모든 컬럼 약간 강조 - return "border-dashed"; - } + if (isDragging) return "border-dashed"; return ""; }; return (
{/* 컬럼 헤더 */} -
+
- -

- {title} -

+ {collapsible && ( + + )} + +

{title}

-
- {/* 지연 태스크 뱃지 - 동그라미에 숫자만 표시 */} +
{overdueCount > 0 && ( )} - {/* 전체 개수 */} - + {tasks.length} + {!collapsed && ( + + )}
- {/* 드래그 오버 시 안내 메시지 */} - {isOver && ( -
- 여기에 놓기 -
- )} - {/* Task Cards */} - task.id)} - strategy={verticalListSortingStrategy} - > -
task.id)} + strategy={verticalListSortingStrategy} > - {tasks.length > 0 - ? tasks.map((task) => ( - onTaskClick(task)} - /> - )) - : !isOver && ( - - )} -
-
+
+ {sortedTasks.length > 0 + ? sortedTasks.map((task) => ( + onTaskClick(task)} + onTitleUpdate={ + onTitleUpdate + ? (t) => onTitleUpdate(task.id, t) + : undefined + } + /> + )) + : !isOver && ( + + )} + + {onAddClick && ( + + )} +
+ + )}
); }; diff --git a/src/components/features/kanban/components/KanbanFilter.tsx b/src/components/features/kanban/components/KanbanFilter.tsx index 78dc7f2..78822c9 100644 --- a/src/components/features/kanban/components/KanbanFilter.tsx +++ b/src/components/features/kanban/components/KanbanFilter.tsx @@ -15,6 +15,7 @@ interface KanbanFilterProps { showFilter: boolean; taskCount: number; totalCount: number; + onReset?: () => void; } export type { KanbanFilterType }; @@ -25,15 +26,18 @@ export default function KanbanFilter({ showFilter, taskCount, totalCount, + onReset, }: KanbanFilterProps) { + const hasActiveFilter = + filter.priority !== "all" || filter.assignee !== "all" || filter.date !== "all"; if (!showFilter) return null; return ( -
+
{/* 우선순위 */}
- + 우선순위
@@ -42,7 +46,7 @@ export default function KanbanFilter({ className={`text-sm px-3 py-1.5 rounded transition-all ${ filter.priority === "all" ? "bg-main-500 text-white" - : "bg-white dark:bg-gray-700 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600" + : "bg-card text-muted-foreground hover:bg-muted/60 hover:text-foreground" }`} > 전체 @@ -52,7 +56,7 @@ export default function KanbanFilter({ className={`text-sm px-3 py-1.5 rounded transition-all ${ filter.priority === "high" ? "bg-main-500 text-white" - : "bg-white dark:bg-gray-700 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600" + : "bg-card text-muted-foreground hover:bg-muted/60 hover:text-foreground" }`} > 높음 @@ -62,7 +66,7 @@ export default function KanbanFilter({ className={`text-sm px-3 py-1.5 rounded transition-all ${ filter.priority === "normal" ? "bg-main-500 text-white" - : "bg-white dark:bg-gray-700 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600" + : "bg-card text-muted-foreground hover:bg-muted/60 hover:text-foreground" }`} > 보통 @@ -72,7 +76,7 @@ export default function KanbanFilter({ className={`text-sm px-3 py-1.5 rounded transition-all ${ filter.priority === "low" ? "bg-main-500 text-white" - : "bg-white dark:bg-gray-700 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600" + : "bg-card text-muted-foreground hover:bg-muted/60 hover:text-foreground" }`} > 낮음 @@ -82,7 +86,7 @@ export default function KanbanFilter({ {/* 담당자 */}
- + 담당자
@@ -91,7 +95,7 @@ export default function KanbanFilter({ className={`text-sm px-3 py-1.5 rounded transition-all ${ filter.assignee === "all" ? "bg-main-500 text-white" - : "bg-white dark:bg-gray-700 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600" + : "bg-card text-muted-foreground hover:bg-muted/60 hover:text-foreground" }`} > 전체 @@ -101,7 +105,7 @@ export default function KanbanFilter({ className={`text-sm px-3 py-1.5 rounded transition-all ${ filter.assignee === "me" ? "bg-main-500 text-white" - : "bg-white dark:bg-gray-700 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600" + : "bg-card text-muted-foreground hover:bg-muted/60 hover:text-foreground" }`} > 내 작업 @@ -111,7 +115,7 @@ export default function KanbanFilter({ className={`text-sm px-3 py-1.5 rounded transition-all ${ filter.assignee === "assigned" ? "bg-main-500 text-white" - : "bg-white dark:bg-gray-700 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600" + : "bg-card text-muted-foreground hover:bg-muted/60 hover:text-foreground" }`} > 할당됨 @@ -121,7 +125,7 @@ export default function KanbanFilter({ className={`text-sm px-3 py-1.5 rounded transition-all ${ filter.assignee === "unassigned" ? "bg-main-500 text-white" - : "bg-white dark:bg-gray-700 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600" + : "bg-card text-muted-foreground hover:bg-muted/60 hover:text-foreground" }`} > 미할당 @@ -131,7 +135,7 @@ export default function KanbanFilter({ {/* 기간 */}
- + 기간
@@ -140,7 +144,7 @@ export default function KanbanFilter({ className={`text-sm px-3 py-1.5 rounded transition-all ${ filter.date === "all" ? "bg-main-500 text-white" - : "bg-white dark:bg-gray-700 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600" + : "bg-card text-muted-foreground hover:bg-muted/60 hover:text-foreground" }`} > 전체 @@ -150,7 +154,7 @@ export default function KanbanFilter({ className={`text-sm px-3 py-1.5 rounded transition-all ${ filter.date === "today" ? "bg-main-500 text-white" - : "bg-white dark:bg-gray-700 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600" + : "bg-card text-muted-foreground hover:bg-muted/60 hover:text-foreground" }`} > 오늘 @@ -160,7 +164,7 @@ export default function KanbanFilter({ className={`text-sm px-3 py-1.5 rounded transition-all ${ filter.date === "thisWeek" ? "bg-main-500 text-white" - : "bg-white dark:bg-gray-700 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600" + : "bg-card text-muted-foreground hover:bg-muted/60 hover:text-foreground" }`} > 이번주 @@ -170,7 +174,7 @@ export default function KanbanFilter({ className={`text-sm px-3 py-1.5 rounded transition-all ${ filter.date === "overdue" ? "bg-main-500 text-white" - : "bg-white dark:bg-gray-700 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600" + : "bg-card text-muted-foreground hover:bg-muted/60 hover:text-foreground" }`} > 지연 @@ -179,10 +183,23 @@ export default function KanbanFilter({
{/* 구분선 + 결과 카운트 */} -
- +
+ {taskCount}개 / 전체 {totalCount}개 + {taskCount === 0 && hasActiveFilter && ( + + — 조건에 맞는 작업이 없어요 + + )} + {hasActiveFilter && onReset && ( + + )}
diff --git a/src/components/features/kanban/components/KanbanHeader.tsx b/src/components/features/kanban/components/KanbanHeader.tsx index 83507ef..e94cd22 100644 --- a/src/components/features/kanban/components/KanbanHeader.tsx +++ b/src/components/features/kanban/components/KanbanHeader.tsx @@ -1,9 +1,10 @@ -// src/components/features/kanban/components/KanbanHeader.tsx "use client"; +import { useState, useRef, useEffect } from "react"; import { format } from "date-fns"; import { ko } from "date-fns/locale"; import { showToast } from "@/lib/utils/toast"; +import { Info, SlidersHorizontal, HelpCircle, Plus, Search, X } from "lucide-react"; interface KanbanHeaderProps { projectName: string; @@ -11,6 +12,9 @@ interface KanbanHeaderProps { onToggleFilter: () => void; onToggleHelp: () => void; showHelp: boolean; + hasActiveFilter?: boolean; + searchQuery?: string; + onSearchChange?: (q: string) => void; tasksCount: number; project?: { project_id?: string; @@ -27,11 +31,25 @@ export default function KanbanHeader({ onToggleFilter, onToggleHelp, showHelp, + hasActiveFilter = false, + searchQuery = "", + onSearchChange, tasksCount, project, onProjectInfoClick, }: KanbanHeaderProps) { - // 프로젝트 기간 정보 계산 + const [searchOpen, setSearchOpen] = useState(false); + const searchRef = useRef(null); + + useEffect(() => { + if (searchOpen) searchRef.current?.focus(); + }, [searchOpen]); + + const closeSearch = () => { + setSearchOpen(false); + onSearchChange?.(""); + }; + const getProjectPeriodInfo = () => { if (!project?.started_at || !project?.ended_at) return null; @@ -40,217 +58,127 @@ export default function KanbanHeader({ const today = new Date(); today.setHours(0, 0, 0, 0); - const startStr = startDate.toLocaleDateString("ko-KR", { - year: "numeric", - month: "short", - day: "numeric", - }); - const endStr = endDate.toLocaleDateString("ko-KR", { - year: "numeric", - month: "short", - day: "numeric", - }); + const startStr = startDate.toLocaleDateString("ko-KR", { year: "numeric", month: "short", day: "numeric" }); + const endStr = endDate.toLocaleDateString("ko-KR", { year: "numeric", month: "short", day: "numeric" }); - // 남은 일수 계산 - const timeDiff = endDate.getTime() - today.getTime(); + const timeDiff = endDate.getTime() - today.getTime(); const remainingDays = Math.ceil(timeDiff / (1000 * 3600 * 24)); + const daysToStart = Math.ceil((startDate.getTime() - today.getTime()) / (1000 * 3600 * 24)); - // 시작까지 남은 일수 - const timeToStart = startDate.getTime() - today.getTime(); - const daysToStart = Math.ceil(timeToStart / (1000 * 3600 * 24)); - - // 프로젝트 상태 판단 - let status: "before" | "active" | "warning" | "ended"; let badgeText: string; - let badgeColor: string; + let badgeClass: string; + let status: "before" | "active" | "warning" | "ended"; if (today < startDate) { - // 시작 전 status = "before"; badgeText = `시작 D-${daysToStart}`; - badgeColor = "bg-blue-500 text-white"; + badgeClass = "bg-main-500/10 text-main-600 dark:bg-main-400/10 dark:text-main-300"; } else if (today > endDate) { - // 종료 status = "ended"; badgeText = "종료"; - badgeColor = "bg-red-500 text-white"; + badgeClass = "bg-red-500/10 text-red-600 dark:bg-red-400/10 dark:text-red-400"; } else if (remainingDays <= 3) { - // 마감 임박 (3일 이하) status = "warning"; badgeText = `D-${remainingDays} ⚠️`; - badgeColor = "bg-orange-500 text-white"; + badgeClass = "bg-amber-500/10 text-amber-600 dark:bg-amber-400/10 dark:text-amber-400"; } else { - // 진행 중 status = "active"; badgeText = `D-${remainingDays}`; - badgeColor = "bg-white/20 text-white"; + badgeClass = "bg-main-500/10 text-main-600 dark:bg-main-400/10 dark:text-main-300"; } - return { - startStr, - endStr, - remainingDays, - status, - badgeText, - badgeColor, - }; + return { startStr, endStr, remainingDays, status, badgeText, badgeClass }; }; const projectPeriod = getProjectPeriodInfo(); - // 프로젝트 종료 체크 const handleAddClick = () => { if (project?.ended_at) { const today = new Date().toISOString().split("T")[0]; - const startDate = project.started_at; - - if (today < startDate!) { - showToast("아직 프로젝트가 시작되지 않았습니다.", "warning"); - return; - } - - if (today > project.ended_at) { - showToast("종료된 프로젝트입니다.", "warning"); - return; - } + if (today < project.started_at!) { showToast("아직 프로젝트가 시작되지 않았습니다.", "warning"); return; } + if (today > project.ended_at) { showToast("종료된 프로젝트입니다.", "warning"); return; } } onAddClick(); }; + const iconBtn = "p-1.5 rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted/40 transition-colors"; + return ( -
-
- {/* 왼쪽: 프로젝트명 + 정보 버튼 + 기간 정보 */} -
+
+
+ {/* 왼쪽: 프로젝트명 + 기간 */} +
-

- {projectName} -

- {/* 프로젝트 정보 버튼 */} -
{projectPeriod && ( -
- | - - {projectPeriod.startStr} ~ {projectPeriod.endStr} - - +
+ {projectPeriod.startStr} ~ {projectPeriod.endStr} + {projectPeriod.badgeText}
)} - {/* 종료된 프로젝트 안내 문구 */} {projectPeriod?.status === "ended" && ( -
- - - - - 이 프로젝트는 종료되었습니다. 일정 추가/수정이 제한됩니다. - -
+

+ 종료된 프로젝트입니다. 일정 추가/수정이 제한됩니다. +

)}
- {/* 오른쪽: 칸반보드 표시 + 작업 개수 + 오늘 날짜 + 버튼들 */} -
- {/* 칸반보드 뷰 표시 */} -
-
- 칸반보드 -
-
- - {/* 작업 개수 및 오늘 날짜 */} -
-
{tasksCount}개 작업
-
- {format(new Date(), "M월 d일 (E)", { locale: ko })} + {/* 오른쪽 */} +
+ {/* 검색 인풋 (열렸을 때) */} + {searchOpen ? ( +
+ + onSearchChange?.(e.target.value)} + onKeyDown={(e) => e.key === "Escape" && closeSearch()} + placeholder="작업 검색..." + className="w-32 sm:w-48 text-sm bg-transparent outline-none text-foreground placeholder:text-muted-foreground" + /> +
-
+ ) : ( + <> +
+
{tasksCount}개 작업
+
{format(new Date(), "M월 d일 (E)", { locale: ko })}
+
+ {onSearchChange && ( + + )} + + )} - {/* 새 작업 버튼 */} - {/* 필터 버튼 */} - - {/* 도움말 버튼 */} -
diff --git a/src/components/features/kanban/components/KanbanLegend.tsx b/src/components/features/kanban/components/KanbanLegend.tsx index 390287b..2bc5061 100644 --- a/src/components/features/kanban/components/KanbanLegend.tsx +++ b/src/components/features/kanban/components/KanbanLegend.tsx @@ -11,6 +11,7 @@ import { useMemo } from "react"; import { Task } from "@/types/kanban"; import { format, addDays } from "date-fns"; import { AlertTriangle, Clock } from "lucide-react"; +import PriorityBadge from "@/features/task/ui/fields/PriorityBadge"; interface KanbanLegendProps { tasks?: Task[]; @@ -74,26 +75,20 @@ export default function KanbanLegend({ if (compact) { // 컴팩트 모드 (모바일용) return ( -
+
{/* 통계 */} - - - {stats.todo} - + + {stats.todo} - - - {stats.inprogress} - + + {stats.inprogress} - - - {stats.done} - + + {stats.done} {stats.overdue > 0 && ( @@ -125,38 +120,32 @@ export default function KanbanLegend({ ); } - // 기본 모드 - CalendarStats와 동일한 스타일 + // 기본 모드 return ( -
+
{/* 왼쪽: 통계 + 범례 */}
{/* 상태별 통계 */}
- - 할일 - - {stats.todo} - + + 할일 + {stats.todo}
- - 진행중 - - {stats.inprogress} - + + 진행중 + {stats.inprogress}
- - 완료 - - {stats.done} - + + 완료 + {stats.done}
{/* 긴급/임박/지연 표시 */} {(stats.urgent > 0 || stats.upcoming > 0 || stats.overdue > 0) && ( <> - | + | {stats.urgent > 0 && (
@@ -188,43 +177,34 @@ export default function KanbanLegend({
- 지연 + 지연 - - 완료 + + 완료
{/* 구분선 */} - | + | {/* 우선순위 범례 */} -
- - - 높음 - - - - 보통 - - - - 낮음 - +
+ + +
{/* 오른쪽: 진행률 */}
-
+
- + {completionRate}% 완료
diff --git a/src/components/ui/SidePanel.tsx b/src/components/ui/SidePanel.tsx new file mode 100644 index 0000000..7b6d885 --- /dev/null +++ b/src/components/ui/SidePanel.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { useEffect } from "react"; + +interface SidePanelProps { + isOpen: boolean; + onClose: () => void; + children: React.ReactNode; +} + +export default function SidePanel({ isOpen, onClose, children }: SidePanelProps) { + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + if (isOpen) document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [isOpen, onClose]); + + useEffect(() => { + document.body.style.overflow = isOpen ? "hidden" : ""; + return () => { document.body.style.overflow = ""; }; + }, [isOpen]); + + return ( + <> + {/* Backdrop */} +
+ + {/* Panel */} +
+ {children} +
+ + ); +} diff --git a/src/features/task/ui/add/TaskAdd.tsx b/src/features/task/ui/add/TaskAdd.tsx index bc3748e..66de70a 100644 --- a/src/features/task/ui/add/TaskAdd.tsx +++ b/src/features/task/ui/add/TaskAdd.tsx @@ -24,6 +24,7 @@ interface TaskAddProps { projectEndedAt?: string; onSuccess?: (task: Omit) => void; onCancel: () => void; + initialStatus?: TaskStatus; initialStartDate?: string; initialEndDate?: string; initialStartTime?: string; @@ -80,6 +81,7 @@ export default function TaskAdd({ projectEndedAt, onSuccess, onCancel, + initialStatus, initialStartDate, initialEndDate, initialStartTime, @@ -88,6 +90,7 @@ export default function TaskAdd({ }: TaskAddProps) { const [formData, setFormData] = useState({ ...INITIAL_FORM_DATA, + status: initialStatus || "todo", started_at: initialStartDate || "", ended_at: initialEndDate || "", start_time: initialStartTime || "", diff --git a/src/features/task/ui/card/TaskCard.tsx b/src/features/task/ui/card/TaskCard.tsx index 2217679..5f17b3b 100644 --- a/src/features/task/ui/card/TaskCard.tsx +++ b/src/features/task/ui/card/TaskCard.tsx @@ -1,73 +1,101 @@ -import { useState, useRef, useEffect, useCallback, useMemo } from "react"; +import { useMemo, useEffect, useState, useRef, useCallback } from "react"; import { useSortable } from "@dnd-kit/sortable"; import { Task } from "@/types"; import { CSS } from "@dnd-kit/utilities"; import { Check } from "lucide-react"; import PriorityBadge from "@/features/task/ui/fields/PriorityBadge"; -import AssigneeInfo from "@/features/task/ui/fields/AssigneeInfo"; -import SubtaskList from "@/features/task/ui/fields/SubtaskList"; import DateInfo from "@/features/task/ui/fields/DateInfo"; interface TaskCardProps { task: Task; projectId: string; onClick?: () => void; - isOverlay?: boolean; // ✨ DragOverlay 모드 + isOverlay?: boolean; + onTitleUpdate?: (title: string) => void; } const TaskCard = ({ task, - projectId, + projectId: _projectId, onClick, isOverlay = false, + onTitleUpdate, }: TaskCardProps) => { - const [isExpanded, setIsExpanded] = useState(false); - const [contentHeight, setContentHeight] = useState(0); const [isNew, setIsNew] = useState(true); - const contentRef = useRef(null); + const [isEditingTitle, setIsEditingTitle] = useState(false); + const [editedTitle, setEditedTitle] = useState(task.title); + const titleInputRef = useRef(null); - // 지연 여부 체크 (마감일+시간이 지났고 완료되지 않은 경우) const isOverdue = useMemo(() => { if (!task.ended_at || task.status === "done") return false; - const now = new Date(); - - // 시간 정보가 있는 경우 (use_time && end_time) if (task.use_time && task.end_time) { const endDateStr = task.ended_at.includes("T") ? task.ended_at.split("T")[0] : task.ended_at; - const deadlineDateTime = new Date(`${endDateStr}T${task.end_time}`); - return now > deadlineDateTime; + return now > new Date(`${endDateStr}T${task.end_time}`); } - - // 날짜만 있는 경우 - 오늘 자정 기준 const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); const endDateStr = task.ended_at.includes("T") ? task.ended_at.split("T")[0] : task.ended_at; const [year, month, day] = endDateStr.split("-").map(Number); - const endDate = new Date(year, month - 1, day); - - return endDate < today; + return new Date(year, month - 1, day) < today; }, [task.ended_at, task.end_time, task.use_time, task.status]); - // 완료 여부 const isCompleted = task.status === "done"; - // 새 카드 애니메이션 (마운트 후 해제) useEffect(() => { const timer = setTimeout(() => setIsNew(false), 500); return () => clearTimeout(timer); }, []); + useEffect(() => { + if (isEditingTitle && titleInputRef.current) { + titleInputRef.current.focus(); + titleInputRef.current.select(); + } + }, [isEditingTitle]); + + const handleTitleSave = useCallback(() => { + const trimmed = editedTitle.trim(); + if (trimmed && trimmed !== task.title) { + onTitleUpdate?.(trimmed); + } + setIsEditingTitle(false); + }, [editedTitle, task.title, onTitleUpdate]); + + const handleTitleDoubleClick = useCallback( + (e: React.MouseEvent) => { + if (isOverlay || !onTitleUpdate) return; + e.stopPropagation(); + setEditedTitle(task.title); + setIsEditingTitle(true); + }, + [isOverlay, onTitleUpdate, task.title] + ); + + const handleTitleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleTitleSave(); + } + if (e.key === "Escape") { + setIsEditingTitle(false); + setEditedTitle(task.title); + } + }, + [handleTitleSave, task.title] + ); + const { attributes, listeners, setNodeRef, transform, isDragging } = useSortable({ id: task.id, animateLayoutChanges: () => false, + disabled: isEditingTitle, }); - // --- 스타일 처리 --- const dragStyle = useMemo( () => ({ transform: transform ? CSS.Transform.toString(transform) : undefined, @@ -78,32 +106,6 @@ const TaskCard = ({ [transform, isDragging, isOverlay] ); - const toggleExpanded = useCallback((e: React.MouseEvent) => { - e.stopPropagation(); - setIsExpanded((prev) => !prev); - }, []); - - const hasAdditionalInfo = useMemo(() => { - return Boolean( - task.description || - task.assigned_user_id || - task.started_at || - task.ended_at || - (task.subtasks && task.subtasks.length > 0) || - task.memo - ); - }, [task]); - - // --- Height 계산 (아코디언) --- - useEffect(() => { - if (!contentRef.current) return; - - const calc = () => setContentHeight(contentRef.current!.scrollHeight); - calc(); - const timer = setTimeout(calc, 80); - return () => clearTimeout(timer); - }, [task, isExpanded]); - return (
- {/* 헤더 */} -
-
- {/* 완료 체크 아이콘 */} - {isCompleted && ( -
- + {/* 제목 행 */} +
+
+ {isCompleted && !isEditingTitle && ( +
+
)} -

- {task.title} -

-
- - {/* 우선순위 + 펼치기 버튼 */} -
- {task.priority && } - {hasAdditionalInfo && !isOverlay && ( - + {task.title} + )}
+ {task.priority && }
- {/* 접혔을 때 날짜 표시 */} - {!isExpanded && !isOverlay && (task.started_at || task.ended_at) && ( -
+ {/* 마감일 */} + {(task.started_at || task.ended_at) && !isOverlay && ( +
)} - {/* 펼쳐진 내용 */} - {!isOverlay && ( -
-
- {task.description && ( -

- {task.description} -

- )} - - {task.assigned_user_id && ( -
- 0 || task.assignee) && ( +
+ {Array.isArray(task.subtasks) && task.subtasks.length > 0 && ( + <> +
+
s.completed).length / + task.subtasks.length) * + 100 + )}%`, + }} />
- )} - - {(task.started_at || task.ended_at) && ( - - )} - - {Array.isArray(task.subtasks) && task.subtasks.length > 0 && ( -
- -
- )} - - {task.memo && ( -
- {task.memo} -
- )} -
+ + {task.subtasks.filter((s) => s.completed).length}/{task.subtasks.length} + + + )} + {task.assignee && ( +
+ {task.assignee.avatar_url ? ( + {task.assignee.name} + ) : ( +
+ {task.assignee.name.slice(0, 1).toUpperCase()} +
+ )} +
+ )}
)}
diff --git a/src/lib/utils/taskUtils.ts b/src/lib/utils/taskUtils.ts index 10da5bd..26e95d2 100644 --- a/src/lib/utils/taskUtils.ts +++ b/src/lib/utils/taskUtils.ts @@ -8,19 +8,19 @@ import { TaskStatus, TaskPriority } from "@/types"; export const getTaskStatusColor = (status: TaskStatus) => { const colors = { todo: { - bg: "bg-gray-400 dark:bg-gray-600", - text: "text-gray-700 dark:text-gray-200", + bg: "bg-gray-500 dark:bg-gray-500", + text: "text-gray-600 dark:text-gray-300", border: "border-gray-300 dark:border-gray-500", }, inprogress: { - bg: "bg-blue-400 dark:bg-blue-500", - text: "text-blue-700 dark:text-blue-200", + bg: "bg-blue-500 dark:bg-blue-400", + text: "text-blue-600 dark:text-blue-300", border: "border-blue-300 dark:border-blue-500", }, done: { - bg: "bg-green-400 dark:bg-green-600", - text: "text-green-700 dark:text-green-300", - border: "border-green-300 dark:border-green-500", + bg: "bg-emerald-500 dark:bg-emerald-400", + text: "text-emerald-600 dark:text-emerald-300", + border: "border-emerald-300 dark:border-emerald-500", }, }; return colors[status];