From 416ef62c562aafa9feda390a05183dcba478bbd4 Mon Sep 17 00:00:00 2001 From: nelee Date: Tue, 12 May 2026 17:39:46 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(calendar):=20=EC=9B=94=EA=B0=84=20?= =?UTF-8?q?=EB=B7=B0=20=EA=B8=B0=EA=B0=84=20=EB=B0=94=20+=20=EB=93=9C?= =?UTF-8?q?=EB=9E=98=EA=B7=B8=20=EB=82=A0=EC=A7=9C=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 시작~종료일이 있는 작업을 다중일 연결 바로 표시 (Jira 스타일) - 첫날: 왼쪽 라운드 캡, 마지막날: 오른쪽 라운드 캡, 중간날: 직사각형 - 레인 자동 배정 알고리즘으로 겹침 없이 최대 3열 표시 - MAX_LANES 초과 시 "+N" 오버플로우 표시 - 작업 카드 드래그 → 다른 날짜로 드롭해 날짜 이동 (기간 유지) - 드롭 대상 날짜 하이라이트 + 드래그 중 카드 반투명 처리 - taskUtils에 getTaskCalendarBarStyle 추가 Closes #69, #70 Co-Authored-By: Claude Sonnet 4.6 --- .../features/calendarView/CalendarView.tsx | 1 + .../calendarView/monthViews/MonthGrid.tsx | 472 +++++++++++++----- .../calendarView/monthViews/index.tsx | 37 +- src/lib/utils/taskUtils.ts | 15 + 4 files changed, 362 insertions(+), 163 deletions(-) diff --git a/src/components/features/calendarView/CalendarView.tsx b/src/components/features/calendarView/CalendarView.tsx index 30171df..c5329aa 100644 --- a/src/components/features/calendarView/CalendarView.tsx +++ b/src/components/features/calendarView/CalendarView.tsx @@ -431,6 +431,7 @@ export default function CalendarView({ setShowTaskDetailModal(true); onSelectTask?.(task); }} + onUpdateTask={onUpdateTask} /> ) : currentView === "day" ? ( diff --git a/src/components/features/calendarView/monthViews/MonthGrid.tsx b/src/components/features/calendarView/monthViews/MonthGrid.tsx index 84d1824..01492a8 100644 --- a/src/components/features/calendarView/monthViews/MonthGrid.tsx +++ b/src/components/features/calendarView/monthViews/MonthGrid.tsx @@ -1,195 +1,405 @@ "use client"; -import { useState, useCallback } from "react"; -import { format, isSameDay } from "date-fns"; +import { useState, useCallback, useMemo } from "react"; +import { + format, + isSameDay, + parseISO, + startOfDay, + endOfDay, + differenceInDays, + addDays, + isBefore, + isAfter, +} from "date-fns"; import { Task } from "@/types/kanban"; -import MonthEventCard from "./MonthEventCard"; +import { getTaskCalendarBarStyle, isTaskOverdue } from "@/lib/utils/taskUtils"; interface MonthGridProps { weeks: Date[][]; - tasksByDate: Record; + tasks: Task[]; isOutsideProjectRange: (date: Date) => boolean; isCurrentMonth: (date: Date) => boolean; onSelectSlot: (date: Date) => void; onSelectRange?: (startDate: Date, endDate: Date) => void; onSelectEvent: (task: Task) => void; + onUpdateTask?: (taskId: string, updates: Partial) => void; } -// 더블클릭 감지 임계값 (ms) -const DOUBLE_CLICK_THRESHOLD = 300; +type EventBar = { + task: Task; + startCol: number; + span: number; + isStart: boolean; + isEnd: boolean; + lane: number; +}; + +const BAR_H = 20; +const BAR_GAP = 3; +const DATE_H = 28; +const MAX_LANES = 3; +const DOUBLE_CLICK_MS = 300; + +function computeWeekBars(week: Date[], tasks: Task[]): EventBar[] { + const weekStart = startOfDay(week[0]); + const weekEnd = endOfDay(week[6]); + + const bars: EventBar[] = []; + + tasks.forEach((task) => { + if (!task.started_at && !task.ended_at) return; + + const taskStart = task.started_at + ? startOfDay(parseISO(task.started_at)) + : startOfDay(parseISO(task.ended_at!)); + const taskEnd = task.ended_at + ? endOfDay(parseISO(task.ended_at)) + : endOfDay(parseISO(task.started_at!)); + + if (isBefore(taskEnd, weekStart) || isAfter(taskStart, weekEnd)) return; + + const displayStart = isBefore(taskStart, weekStart) ? weekStart : taskStart; + const displayEnd = isAfter(taskEnd, weekEnd) ? weekEnd : taskEnd; + + const startIdx = week.findIndex((d) => isSameDay(d, displayStart)); + const endIdx = week.findIndex((d) => isSameDay(d, displayEnd)); + const sc = startIdx >= 0 ? startIdx : 0; + const ec = endIdx >= 0 ? endIdx : 6; + + bars.push({ + task, + startCol: sc, + span: Math.max(1, ec - sc + 1), + isStart: !isBefore(taskStart, weekStart), + isEnd: !isAfter(taskEnd, weekEnd), + lane: 0, + }); + }); + + // Sort: earlier start first, then longer span first + bars.sort((a, b) => + a.startCol !== b.startCol ? a.startCol - b.startCol : b.span - a.span + ); + + // Greedy lane assignment + const laneEnds: number[] = []; + bars.forEach((bar) => { + let placed = false; + for (let i = 0; i < laneEnds.length; i++) { + if (laneEnds[i] < bar.startCol) { + bar.lane = i; + laneEnds[i] = bar.startCol + bar.span - 1; + placed = true; + break; + } + } + if (!placed) { + bar.lane = laneEnds.length; + laneEnds.push(bar.startCol + bar.span - 1); + } + }); + + return bars; +} export default function MonthGrid({ weeks, - tasksByDate, + tasks, isOutsideProjectRange, isCurrentMonth, onSelectSlot, onSelectRange, onSelectEvent, + onUpdateTask, }: MonthGridProps) { - // 더블클릭 감지를 위한 상태 - const [lastClickTime, setLastClickTime] = useState(0); - const [lastClickedSlot, setLastClickedSlot] = useState(""); - - // 드래그 선택을 위한 상태 - const [isDragging, setIsDragging] = useState(false); - const [dragStartDate, setDragStartDate] = useState(null); - const [dragEndDate, setDragEndDate] = useState(null); - - // 드래그 시작 - const handleMouseDown = useCallback((day: Date, isOutside: boolean) => { - if (isOutside) return; - setIsDragging(true); - setDragStartDate(day); - setDragEndDate(day); - }, []); + // ── Range-select state (create new task) ────────────────────────────────── + const [lastClickTime, setLastClickTime] = useState(0); + const [lastClickedSlot, setLastClickedSlot] = useState(""); + const [isRangeDragging, setIsRangeDragging] = useState(false); + const [rangeStart, setRangeStart] = useState(null); + const [rangeEnd, setRangeEnd] = useState(null); + + // ── Drag-to-reschedule state ─────────────────────────────────────────────── + const [draggedTask, setDraggedTask] = useState(null); + const [dropTargetDate, setDropTargetDate] = useState(null); - // 드래그 중 - const handleMouseEnter = useCallback( + // ── Per-week bar layouts ─────────────────────────────────────────────────── + const weekBars = useMemo( + () => weeks.map((week) => computeWeekBars(week, tasks)), + [weeks, tasks] + ); + + // ── Range-select handlers ───────────────────────────────────────────────── + const handleCellMouseDown = useCallback( + (day: Date, isOutside: boolean) => { + if (isOutside || draggedTask) return; + setIsRangeDragging(true); + setRangeStart(day); + setRangeEnd(day); + }, + [draggedTask] + ); + + const handleCellMouseEnter = useCallback( (day: Date, isOutside: boolean) => { - if (!isDragging || isOutside) return; - setDragEndDate(day); + if (!isRangeDragging || isOutside) return; + setRangeEnd(day); }, - [isDragging] + [isRangeDragging] ); - // 드래그 종료 const handleMouseUp = useCallback(() => { - if (!isDragging || !dragStartDate || !dragEndDate) { - setIsDragging(false); + if (!isRangeDragging || !rangeStart || !rangeEnd) { + setIsRangeDragging(false); return; } + const start = rangeStart <= rangeEnd ? rangeStart : rangeEnd; + const end = rangeStart <= rangeEnd ? rangeEnd : rangeStart; - // 시작일과 종료일 정렬 - const start = dragStartDate <= dragEndDate ? dragStartDate : dragEndDate; - const end = dragStartDate <= dragEndDate ? dragEndDate : dragStartDate; - - // 여러 날짜 선택 시 범위 선택 콜백 호출 if (!isSameDay(start, end) && onSelectRange) { onSelectRange(start, end); } + setIsRangeDragging(false); + setRangeStart(null); + setRangeEnd(null); + }, [isRangeDragging, rangeStart, rangeEnd, onSelectRange]); - // 상태 초기화 - setIsDragging(false); - setDragStartDate(null); - setDragEndDate(null); - }, [isDragging, dragStartDate, dragEndDate, onSelectRange]); - - // 날짜가 드래그 범위 내에 있는지 확인 - const isInDragRange = useCallback( + const isInRangeSelect = useCallback( (day: Date) => { - if (!isDragging || !dragStartDate || !dragEndDate) return false; - const start = dragStartDate <= dragEndDate ? dragStartDate : dragEndDate; - const end = dragStartDate <= dragEndDate ? dragEndDate : dragStartDate; - return day >= start && day <= end; + if (!isRangeDragging || !rangeStart || !rangeEnd) return false; + const s = rangeStart <= rangeEnd ? rangeStart : rangeEnd; + const e = rangeStart <= rangeEnd ? rangeEnd : rangeStart; + return day >= s && day <= e; }, - [isDragging, dragStartDate, dragEndDate] + [isRangeDragging, rangeStart, rangeEnd] ); - // 슬롯 클릭 핸들러 (더블클릭 감지) const handleSlotClick = useCallback( (day: Date, isOutside: boolean) => { if (isOutside) return; - - const slotKey = format(day, "yyyy-MM-dd"); + const key = format(day, "yyyy-MM-dd"); const now = Date.now(); - const timeDiff = now - lastClickTime; - - // 더블클릭 감지 - if (slotKey === lastClickedSlot && timeDiff < DOUBLE_CLICK_THRESHOLD) { - // 더블클릭! 일정 추가 모달 열기 + if (key === lastClickedSlot && now - lastClickTime < DOUBLE_CLICK_MS) { onSelectSlot(day); setLastClickTime(0); setLastClickedSlot(""); } else { - // 첫 클릭 - 대기 상태 setLastClickTime(now); - setLastClickedSlot(slotKey); + setLastClickedSlot(key); } }, [lastClickTime, lastClickedSlot, onSelectSlot] ); + // ── Drag-to-reschedule handlers ─────────────────────────────────────────── + const handleBarDragStart = useCallback( + (e: React.DragEvent, task: Task) => { + e.stopPropagation(); + e.dataTransfer.effectAllowed = "move"; + setDraggedTask(task); + // Prevent range-select from firing + setIsRangeDragging(false); + }, + [] + ); + + const handleCellDragOver = useCallback( + (e: React.DragEvent, day: Date, isOutside: boolean) => { + if (!draggedTask || isOutside) return; + e.preventDefault(); + e.dataTransfer.dropEffect = "move"; + setDropTargetDate(day); + }, + [draggedTask] + ); + + const handleCellDrop = useCallback( + (e: React.DragEvent, day: Date, isOutside: boolean) => { + e.preventDefault(); + if (!draggedTask || isOutside || !onUpdateTask) { + setDraggedTask(null); + setDropTargetDate(null); + return; + } + + const duration = + draggedTask.started_at && draggedTask.ended_at + ? differenceInDays( + parseISO(draggedTask.ended_at), + parseISO(draggedTask.started_at) + ) + : 0; + + const newStart = day; + const newEnd = addDays(newStart, duration); + + onUpdateTask(draggedTask.id, { + started_at: format(newStart, "yyyy-MM-dd"), + ended_at: format(newEnd, "yyyy-MM-dd"), + }); + + setDraggedTask(null); + setDropTargetDate(null); + }, + [draggedTask, onUpdateTask] + ); + + const handleDragEnd = useCallback(() => { + setDraggedTask(null); + setDropTargetDate(null); + }, []); + return (
- {weeks.map((week, weekIndex) => ( -
- {week.map((day) => { - const dateKey = format(day, "yyyy-MM-dd"); - const dayTasks = tasksByDate[dateKey] || []; - const isOutside = isOutsideProjectRange(day); - const isOtherMonth = !isCurrentMonth(day); - const isToday = isSameDay(day, new Date()); - const dayOfWeek = day.getDay(); - const isSunday = dayOfWeek === 0; - const isSaturday = dayOfWeek === 6; - const inDragRange = isInDragRange(day); - - return ( -
handleSlotClick(day, isOutside)} - onMouseDown={() => handleMouseDown(day, isOutside)} - onMouseEnter={() => handleMouseEnter(day, isOutside)} - > - {/* 날짜 숫자 */} -
{ + const bars = weekBars[weekIdx]; + + // Count overflowing bars per column + const overflowByCol: Record = {}; + bars.forEach((bar) => { + if (bar.lane >= MAX_LANES) { + for (let c = bar.startCol; c < bar.startCol + bar.span; c++) { + overflowByCol[c] = (overflowByCol[c] ?? 0) + 1; + } + } + }); + + return ( +
+ {/* ── Date cells layer ─────────────────────────────────────── */} +
+ {week.map((day) => { + const dateKey = format(day, "yyyy-MM-dd"); + const isOutside = isOutsideProjectRange(day); + const isOtherMonth = !isCurrentMonth(day); + const isToday = isSameDay(day, new Date()); + const dow = day.getDay(); + const inRange = isInRangeSelect(day); + const isDropTarget = + dropTargetDate && isSameDay(dropTargetDate, day); + + return ( +
- {format(day, "d")} -
- - {/* 이벤트 표시 */} - {dayTasks.length > 0 && ( - - )} -
- ); - })} -
- ))} + ? "bg-gray-50 dark:bg-gray-800/50" + : dow === 6 + ? "bg-blue-50/30 dark:bg-blue-900/10" + : dow === 0 + ? "bg-red-50/30 dark:bg-red-900/10" + : "bg-white dark:bg-gray-900 hover:bg-gray-50 dark:hover:bg-gray-800", + ].join(" ")} + onClick={() => handleSlotClick(day, isOutside)} + onMouseDown={() => handleCellMouseDown(day, isOutside)} + onMouseEnter={() => handleCellMouseEnter(day, isOutside)} + onDragOver={(e) => handleCellDragOver(e, day, isOutside)} + onDrop={(e) => handleCellDrop(e, day, isOutside)} + onDragLeave={() => setDropTargetDate(null)} + > + {/* Date number */} +
+ {format(day, "d")} +
+ + {/* Overflow indicator */} + {overflowByCol[week.indexOf(day)] != null && ( +
+ +{overflowByCol[week.indexOf(day)]} +
+ )} +
+ ); + })} +
+ + {/* ── Event bars layer ─────────────────────────────────────── */} +
+ {bars + .filter((bar) => bar.lane < MAX_LANES) + .map((bar) => { + const overdue = isTaskOverdue(bar.task); + const colorCls = getTaskCalendarBarStyle( + bar.task.status, + overdue + ); + const isDragging = + draggedTask?.id === bar.task.id; + + const leftPx = bar.isStart ? 2 : 0; + const rightPx = bar.isEnd ? 2 : 0; + + return ( +
handleBarDragStart(e, bar.task)} + onDragEnd={handleDragEnd} + onClick={(e) => { + e.stopPropagation(); + onSelectEvent(bar.task); + }} + title={bar.task.title} + className={[ + "absolute flex items-center px-1.5 text-[11px] font-medium truncate", + "pointer-events-auto cursor-grab active:cursor-grabbing", + "transition-opacity", + colorCls, + bar.isStart ? "rounded-l-full" : "rounded-l-none", + bar.isEnd ? "rounded-r-full" : "rounded-r-none", + isDragging ? "opacity-40" : "opacity-100 hover:brightness-90", + ].join(" ")} + style={{ + height: BAR_H, + top: bar.lane * (BAR_H + BAR_GAP), + left: `calc(${(bar.startCol / 7) * 100}% + ${leftPx}px)`, + width: `calc(${(bar.span / 7) * 100}% - ${leftPx + rightPx}px)`, + }} + > + {bar.isStart && bar.task.title} +
+ ); + })} +
+
+ ); + })}
); } diff --git a/src/components/features/calendarView/monthViews/index.tsx b/src/components/features/calendarView/monthViews/index.tsx index edcc631..fd65a0d 100644 --- a/src/components/features/calendarView/monthViews/index.tsx +++ b/src/components/features/calendarView/monthViews/index.tsx @@ -10,6 +10,7 @@ import { addDays, isSameMonth, } from "date-fns"; + import { Task } from "@/types/kanban"; import MonthGrid from "./MonthGrid"; @@ -21,6 +22,7 @@ interface MonthViewProps { onSelectSlot: (date: Date) => void; onSelectRange?: (startDate: Date, endDate: Date) => void; onSelectEvent: (task: Task) => void; + onUpdateTask?: (taskId: string, updates: Partial) => void; } export default function MonthView({ @@ -31,6 +33,7 @@ export default function MonthView({ onSelectSlot, onSelectRange, onSelectEvent, + onUpdateTask, }: MonthViewProps) { // 월의 시작일과 끝일 const monthStart = useMemo(() => startOfMonth(currentDate), [currentDate]); @@ -68,37 +71,6 @@ export default function MonthView({ return result; }, [calendarDays]); - // 날짜별 태스크 그룹핑 - const tasksByDate = useMemo(() => { - const grouped: Record = {}; - - tasks.forEach((task) => { - if (!task.started_at && !task.ended_at) return; - - const startDate = task.started_at - ? new Date(task.started_at) - : new Date(task.ended_at!); - const endDate = task.ended_at - ? new Date(task.ended_at) - : new Date(task.started_at!); - - // 시작일부터 종료일까지 모든 날짜에 태스크 추가 - let current = new Date(startDate); - current.setHours(0, 0, 0, 0); - const end = new Date(endDate); - end.setHours(23, 59, 59, 999); - - while (current <= end) { - const dateKey = format(current, "yyyy-MM-dd"); - if (!grouped[dateKey]) grouped[dateKey] = []; - grouped[dateKey].push(task); - current = addDays(current, 1); - } - }); - - return grouped; - }, [tasks]); - // 프로젝트 범위 체크 const isOutsideProjectRange = useCallback( (date: Date) => { @@ -144,12 +116,13 @@ export default function MonthView({ {/* 날짜 그리드 */} ); diff --git a/src/lib/utils/taskUtils.ts b/src/lib/utils/taskUtils.ts index 26e95d2..7f294ef 100644 --- a/src/lib/utils/taskUtils.ts +++ b/src/lib/utils/taskUtils.ts @@ -110,6 +110,21 @@ export const getTaskStatusDotColor = (status: TaskStatus) => { return colors[status] || colors.todo; }; +// 월간 캘린더 기간 바 색상 +export const getTaskCalendarBarStyle = ( + status: TaskStatus, + overdue: boolean +): string => { + if (overdue) + return "bg-red-400 dark:bg-red-500 text-white"; + const styles: Record = { + todo: "bg-gray-400 dark:bg-gray-500 text-white", + inprogress: "bg-blue-400 dark:bg-blue-500 text-white", + done: "bg-green-400 dark:bg-green-500 text-white", + }; + return styles[status] ?? styles.todo; +}; + // 캘린더 호버 리스트용 배경색 (칸반보드와 통일) export const getTaskStatusBgColor = (status: TaskStatus) => { const styles = { From 262d36494b2db225173641d94ed1ddb11fca1f05 Mon Sep 17 00:00:00 2001 From: nelee Date: Tue, 12 May 2026 19:43:37 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix(rls):=20Task=20CRUD=EB=A5=BC=20API=20ro?= =?UTF-8?q?ute=20=EA=B2=BD=EC=9C=A0=EB=A1=9C=20=EB=B3=80=EA=B2=BD=20(supab?= =?UTF-8?q?aseAdmin)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 클라이언트 Supabase는 NextAuth 구조상 user JWT 없어 RLS 차단됨. createTask/updateTask/deleteTask 직접 호출 → fetch /api/kanban/tasks 로 변경. route.ts도 tasks.ts import 제거 후 supabaseAdmin 직접 사용. Co-Authored-By: Claude Sonnet 4.6 --- .../(main)/project/workspace/[id]/page.tsx | 30 ++-- src/app/api/kanban/tasks/route.ts | 155 +++++++++--------- 2 files changed, 90 insertions(+), 95 deletions(-) diff --git a/src/app/(main)/project/workspace/[id]/page.tsx b/src/app/(main)/project/workspace/[id]/page.tsx index be7ffb9..050ed90 100644 --- a/src/app/(main)/project/workspace/[id]/page.tsx +++ b/src/app/(main)/project/workspace/[id]/page.tsx @@ -11,12 +11,7 @@ import BottomNavigation from "@/components/layout/BottomNavigation"; import { Task } from "@/types/kanban"; import { showToast } from "@/lib/utils/toast"; -import { - getTasksByBoardId, - createTask, - updateTask, - deleteTask, -} from "@/app/api/tasks/tasks"; +import { getTasksByBoardId } from "@/app/api/tasks/tasks"; import { useSession } from "next-auth/react"; import { supabase } from "@/lib/supabase/supabase"; @@ -224,9 +219,14 @@ export default function ProjectPage() { const createTaskMutation = useMutation({ mutationFn: async (taskData: Omit) => { - const { data, error } = await createTask(taskData); - if (error) throw error; - return data; + const res = await fetch("/api/kanban/tasks", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(taskData), + }); + if (!res.ok) throw new Error("Failed to create task"); + const json = await res.json(); + return json.data as Task; }, onSuccess: (data) => { if (data) { @@ -242,8 +242,12 @@ export default function ProjectPage() { const updateTaskMutation = useMutation({ mutationFn: async ({ taskId, updates }: { taskId: string; updates: Partial }) => { - const { error } = await updateTask(taskId, updates); - if (error) throw error; + const res = await fetch("/api/kanban/tasks", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id: taskId, ...updates }), + }); + if (!res.ok) throw new Error("Failed to update task"); return { taskId, updates }; }, onSuccess: ({ taskId, updates }) => { @@ -255,8 +259,8 @@ export default function ProjectPage() { const deleteTaskMutation = useMutation({ mutationFn: async (taskId: string) => { - const { error } = await deleteTask(taskId); - if (error) throw error; + const res = await fetch(`/api/kanban/tasks?id=${taskId}`, { method: "DELETE" }); + if (!res.ok) throw new Error("Failed to delete task"); return taskId; }, onSuccess: (taskId) => { diff --git a/src/app/api/kanban/tasks/route.ts b/src/app/api/kanban/tasks/route.ts index 30554d8..2bd67cf 100644 --- a/src/app/api/kanban/tasks/route.ts +++ b/src/app/api/kanban/tasks/route.ts @@ -1,72 +1,81 @@ import { NextRequest, NextResponse } from "next/server"; -import { - getTasksByBoardId, - createTask, - updateTask, - deleteTask, -} from "@/app/api/tasks/tasks"; +import { supabaseAdmin } from "@/lib/supabase/server"; + +const DB_TASK_FIELDS = [ + "kanban_board_id", + "project_id", + "title", + "description", + "status", + "priority", + "assigned_user_id", + "started_at", + "ended_at", + "start_time", + "end_time", + "use_time", + "memo", + "subtasks", +] as const; + +function sanitize(data: Record): Record { + const out: Record = {}; + DB_TASK_FIELDS.forEach((f) => { + if (f in data && data[f] !== undefined && data[f] !== "") { + out[f] = data[f]; + } + }); + return out; +} export async function GET(request: NextRequest) { try { - const { searchParams } = new URL(request.url); - const boardId = searchParams.get("boardId"); - + const boardId = request.nextUrl.searchParams.get("boardId"); if (!boardId) { - return NextResponse.json( - { error: "boardId is required" }, - { status: 400 } - ); + return NextResponse.json({ error: "boardId is required" }, { status: 400 }); } - const { data, error } = await getTasksByBoardId(boardId); + const { data, error } = await supabaseAdmin + .from("tasks") + .select("*, kanban_boards!inner(project_id)") + .eq("kanban_boards.project_id", boardId) + .order("created_at", { ascending: false }); - if (error) { - console.error("Tasks 조회 실패:", error); - return NextResponse.json( - { error: "Failed to fetch tasks" }, - { status: 500 } - ); - } + if (error) throw error; + + const tasks = (data || []).map((task: Record) => { + const { kanban_boards, ...rest } = task as Record & { kanban_boards: { project_id: string } }; + return { ...rest, project_id: kanban_boards.project_id }; + }); - return NextResponse.json({ data: data || [] }); + return NextResponse.json({ data: tasks }); } catch (error) { console.error("GET /api/kanban/tasks error:", error); - return NextResponse.json( - { error: "Internal server error" }, - { status: 500 } - ); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); } } export async function POST(request: NextRequest) { try { const body = await request.json(); + const { id: _id, created_at: _ca, updated_at: _ua, ...rest } = body; + const clean = sanitize(rest); - // id, created_at, updated_at 제거 (Supabase가 자동 생성) - const { - id: _id, - created_at: _created_at, - updated_at: _updated_at, - ...taskData - } = body; - - const { data, error } = await createTask(taskData); - - if (error) { - console.error("Task 생성 실패:", error); - return NextResponse.json( - { error: "Failed to create task" }, - { status: 500 } - ); + if (!clean.kanban_board_id || !clean.title) { + return NextResponse.json({ error: "kanban_board_id and title are required" }, { status: 400 }); } + const { data, error } = await supabaseAdmin + .from("tasks") + .insert(clean) + .select() + .single(); + + if (error) throw error; return NextResponse.json({ data }, { status: 201 }); } catch (error) { console.error("POST /api/kanban/tasks error:", error); - return NextResponse.json( - { error: "Internal server error" }, - { status: 500 } - ); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); } } @@ -76,60 +85,42 @@ export async function PUT(request: NextRequest) { const { id, ...updates } = body; if (!id) { - return NextResponse.json( - { error: "Task ID is required" }, - { status: 400 } - ); + return NextResponse.json({ error: "Task ID is required" }, { status: 400 }); } - const { data, error } = await updateTask(id, updates); - - if (error) { - console.error("Task 업데이트 실패:", error); - return NextResponse.json( - { error: "Failed to update task" }, - { status: 500 } - ); + const clean = sanitize(updates); + if (Object.keys(clean).length === 0) { + return NextResponse.json({ error: "No valid fields to update" }, { status: 400 }); } + const { data, error } = await supabaseAdmin + .from("tasks") + .update(clean) + .eq("id", id) + .select() + .single(); + + if (error) throw error; return NextResponse.json({ data }); } catch (error) { console.error("PUT /api/kanban/tasks error:", error); - return NextResponse.json( - { error: "Internal server error" }, - { status: 500 } - ); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); } } export async function DELETE(request: NextRequest) { try { - const { searchParams } = new URL(request.url); - const id = searchParams.get("id"); - + const id = request.nextUrl.searchParams.get("id"); if (!id) { - return NextResponse.json( - { error: "Task ID is required" }, - { status: 400 } - ); + return NextResponse.json({ error: "Task ID is required" }, { status: 400 }); } - const { error } = await deleteTask(id); - - if (error) { - console.error("Task 삭제 실패:", error); - return NextResponse.json( - { error: "Failed to delete task" }, - { status: 500 } - ); - } + const { error } = await supabaseAdmin.from("tasks").delete().eq("id", id); + if (error) throw error; return NextResponse.json({ success: true }); } catch (error) { console.error("DELETE /api/kanban/tasks error:", error); - return NextResponse.json( - { error: "Internal server error" }, - { status: 500 } - ); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); } }