diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..3ba13e0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1 @@ +blank_issues_enabled: false diff --git a/.github/ISSUE_TEMPLATE/sprint-task.yml b/.github/ISSUE_TEMPLATE/sprint-task.yml new file mode 100644 index 0000000..1b7e65d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/sprint-task.yml @@ -0,0 +1,68 @@ +name: Sprint Task +description: Refactor or feature task for sprint backlog +title: "[type][area] " +labels: ["type:refactor", "priority:medium"] +body: + - type: dropdown + id: type + attributes: + label: Type + options: + - refactor + - feature + - chore + validations: + required: true + - type: dropdown + id: area + attributes: + label: Area + options: + - structure + - kanban + - project + - notice + - auth + - admin + - ui + - api + validations: + required: true + - type: dropdown + id: priority + attributes: + label: Priority + options: + - high + - medium + - low + validations: + required: true + - type: textarea + id: summary + attributes: + label: Summary + description: What should be changed? + placeholder: Describe task in 2-4 lines. + validations: + required: true + - type: textarea + id: acceptance + attributes: + label: Acceptance Criteria + description: Checkbox list recommended. + value: | + - [ ] Criteria 1 + - [ ] Criteria 2 + validations: + required: true + - type: textarea + id: test + attributes: + label: Test Plan + placeholder: Unit/manual/e2e test plan + - type: input + id: estimate + attributes: + label: Estimate (story points) + placeholder: e.g. 2, 3, 5 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..7eaf958 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,29 @@ +name: CI + +on: + pull_request: + branches: + - main + +jobs: + quality: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Type check + run: npm run typecheck + + - name: Lint + run: npm run lint + + - name: Build + run: npm run build \ No newline at end of file diff --git a/.gitignore b/.gitignore index c5a0b70..e02654d 100644 --- a/.gitignore +++ b/.gitignore @@ -98,6 +98,9 @@ dist # Sveltekit cache directory .svelte-kit/ +# VS Code workspace settings +.vscode/ + # vitepress build output **/.vitepress/dist diff --git a/lib/calendarUtils.ts b/lib/calendarUtils.ts deleted file mode 100644 index c2dcd45..0000000 --- a/lib/calendarUtils.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { CalendarEvent, TaskForCalendar } from "./../app/types/calendar"; - -/** - * Task를 Calendar Event로 변환 - */ -export function taskToCalendarEvent( - task: TaskForCalendar -): CalendarEvent | null { - // 시작일이나 종료일이 없으면 캘린더에 표시하지 않음 - if (!task.start_date && !task.end_date) { - return null; - } - - // 시작일이 없으면 종료일을 시작일로 사용 - const startDate = task.start_date - ? new Date(task.start_date) - : new Date(task.end_date!); - - // 종료일이 없으면 시작일을 종료일로 사용 - const endDate = task.end_date - ? new Date(task.end_date) - : new Date(task.start_date!); - - return { - id: task.id, - title: task.title, - start: startDate, - end: endDate, - resource: { - taskId: task.id, - projectId: task.project_id, - status: task.status, - priority: task.priority, - assignee: task.assignee_id, - }, - }; -} - -/** - * Task 배열을 Calendar Event 배열로 변환 - */ -export function tasksToCalendarEvents( - tasks: TaskForCalendar[] -): CalendarEvent[] { - return tasks - .map(taskToCalendarEvent) - .filter((event): event is CalendarEvent => event !== null); -} diff --git a/lib/constants.ts b/lib/constants.ts deleted file mode 100644 index 92d8739..0000000 --- a/lib/constants.ts +++ /dev/null @@ -1,164 +0,0 @@ -// 상수 & 목 데이터 - -import { Task, TaskStatus, TaskPriority } from "@/app/types"; - -/** - * Task: Task 인터페이스 타입 - * TaskStatus: "todo" | "inprogress" | "done" 타입 - * TaskPriority: "low" | "normal" | "high" 타입 - */ - -// ============================================ -// UI에서 사용할 칸반보드 열 정의 -// ============================================ - -export const KANBAN_COLUMNS = [ - { id: "todo" as const, title: "할 일" }, - { id: "inprogress" as const, title: "진행 중" }, - { id: "done" as const, title: "완료" }, -]; - -// ============================================ -// 우선순위별 색상 (Tailwind CSS 클래스) -// ============================================ -export const PRIORITY_COLORS = { - low: "bg-green-100/40 text-green-800", - normal: "bg-yellow-100/40 text-yellow-800", - high: "bg-red-100/40 text-red-800", -}; - -/** - * 💡 사용 예시: - * - * 높음 - * - */ - -// ============================================ -// 테스트용 목 데이터 - 실제 DB 구조 그대로! -// ============================================ -export const MOCK_TASKS_DATA = { - todo: [ - { - id: "task-1", - kanban_board_id: "board-1", - title: "로그인 페이지 디자인", - description: - "Figma에서 작성된 디자인을 바탕으로 로그인 페이지를 구현합니다.", - status: "todo" as TaskStatus, - priority: "high" as TaskPriority, - assigned_to: "user-1", - subtasks: [ - { - id: "sub-1", - title: "와이어프레임 작성", - completed: true, - }, - { - id: "sub-2", - title: "UI 컴포넌트 구현", - completed: false, - }, - ], - memo: "디자인 시스템 참고하기", - started_at: "2025-11-15", - ended_at: "2025-11-20", - created_at: "2025-11-12T10:00:00Z", - updated_at: "2025-11-12T10:00:00Z", - }, - { - id: "task-2", - kanban_board_id: "board-1", - title: "API 문서 작성", - description: "REST API 엔드포인트에 대한 상세 문서를 작성합니다.", - status: "todo" as TaskStatus, - priority: "normal" as TaskPriority, - assigned_to: "user-2", - memo: "Swagger 사용", - ended_at: "2025-11-22", - created_at: "2025-11-12T10:00:00Z", - updated_at: "2025-11-12T10:00:00Z", - }, - { - id: "task-3", - kanban_board_id: "board-1", - title: "이메일 초대 기능", - description: "프로젝트 멤버를 이메일로 초대할 수 있는 기능을 구현합니다.", - status: "todo" as TaskStatus, - priority: "high" as TaskPriority, - assigned_to: "user-1", - started_at: "2025-11-18", - ended_at: "2025-11-25", - created_at: "2025-11-12T10:00:00Z", - updated_at: "2025-11-12T10:00:00Z", - }, - ] as Task[], - - inprogress: [ - { - id: "task-4", - kanban_board_id: "board-1", - title: "데이터베이스 스키마 설계", - description: "Supabase 테이블 구조를 설계하고 생성합니다.", - status: "inprogress" as TaskStatus, - priority: "high" as TaskPriority, - assigned_to: "user-3", - subtasks: [ - { id: "sub-3", title: "ERD 작성", completed: true }, - { id: "sub-4", title: "SQL 스크립트 작성", completed: false }, - { id: "sub-5", title: "테이블 생성", completed: false }, - ], - started_at: "2025-11-10", - ended_at: "2025-11-18", - created_at: "2025-11-10T10:00:00Z", - updated_at: "2025-11-12T10:00:00Z", - }, - { - id: "task-5", - kanban_board_id: "board-1", - title: "드래그 앤 드롭 기능", - description: "칸반보드에서 Task를 드래그해서 이동할 수 있게 만듭니다.", - status: "inprogress" as TaskStatus, - priority: "normal" as TaskPriority, - assigned_to: "user-2", - memo: "react-beautiful-dnd 라이브러리 사용", - ended_at: "2025-11-20", - created_at: "2025-11-11T10:00:00Z", - updated_at: "2025-11-12T10:00:00Z", - }, - ] as Task[], - - done: [ - { - id: "task-6", - kanban_board_id: "board-1", - title: "프로젝트 초기 설정", - description: - "Next.js 프로젝트 생성 및 필요한 라이브러리 설치를 완료했습니다.", - status: "done" as TaskStatus, - priority: "low" as TaskPriority, - assigned_to: "user-4", - started_at: "2025-11-08", - ended_at: "2025-11-10", - created_at: "2025-11-08T10:00:00Z", - updated_at: "2025-11-10T10:00:00Z", - }, - { - id: "task-7", - kanban_board_id: "board-1", - title: "TypeScript 타입 정의", - description: "프로젝트에 필요한 기본 타입들을 정의했습니다.", - status: "done" as TaskStatus, - priority: "normal" as TaskPriority, - assigned_to: "user-1", - subtasks: [ - { id: "sub-6", title: "Task 타입 정의", completed: true }, - { id: "sub-7", title: "KanbanBoard 타입 정의", completed: true }, - ], - started_at: "2025-11-11", - ended_at: "2025-11-12", - created_at: "2025-11-11T10:00:00Z", - updated_at: "2025-11-12T10:00:00Z", - }, - ] as Task[], -}; diff --git a/lib/constants/messages.ts b/lib/constants/messages.ts deleted file mode 100644 index 323853d..0000000 --- a/lib/constants/messages.ts +++ /dev/null @@ -1,14 +0,0 @@ -export const TASK_MESSAGES = { - CREATED: "작업이 생성되었습니다.", - UPDATED: "작업이 저장되었습니다.", - DELETED: "작업이 삭제되었습니다.", - DELETE_CONFIRM: "정말 이 작업을 삭제하시겠습니까?", - UNSAVED_CHANGES: "변경 사항이 있습니다. 저장하시겠습니까?", - CREATE_FAILED: "작업 생성에 실패했습니다. 다시 시도해주세요.", -} as const; - -export const VALIDATION_MESSAGES = { - TITLE_REQUIRED: "제목은 필수입니다.", - INVALID_DATE: "날짜 형식이 잘못되었습니다.", - END_BEFORE_START: "종료일은 시작일보다 늦어야 합니다.", -} as const; diff --git a/lib/noticeService.ts b/lib/noticeService.ts deleted file mode 100644 index 90a7180..0000000 --- a/lib/noticeService.ts +++ /dev/null @@ -1,174 +0,0 @@ -import { mockNotices, Notice, STORAGE_KEY } from "../src/app/data/mockNotices"; - -// ------------------------------------------------------ -// mock 데이터 사용 여부 플래그 설정 -// true: localStorage 사용, -// false: api route 사용 -// ------------------------------------------------------ -const USE_MOCK = false; - -// ------------------------------------------------------ -// 정렬 헬퍼 -// ------------------------------------------------------ -function sortNotices(notices: Notice[]) { - return notices.sort((a, b) => { - if (a.is_important !== b.is_important) { - return a.is_important ? -1 : 1; // pinned가 먼저 - } - return new Date(b.created_at).getTime() - new Date(a.created_at).getTime(); - }); -} - -// ------------------------------------------------------ -// 공지사항 목록 조회 -// ------------------------------------------------------ - -export async function getNotices(): Promise { - if (USE_MOCK) { - // ------- 로컬스토리지 사용 ------- // - if (typeof window === "undefined") { - return []; - } - - try { - const savedNoticesJson = localStorage.getItem(STORAGE_KEY); - - let notices: Notice[]; - - if (savedNoticesJson) { - // 저장된 데이터가 있다면 - notices = JSON.parse(savedNoticesJson); - } else { - notices = mockNotices; - localStorage.setItem(STORAGE_KEY, JSON.stringify(mockNotices)); - } - return sortNotices(notices); - } catch (error) { - console.error("공지사항 조회 오류: ", error); - return []; - } - } else { - // ------- api route 사용 ------- // - try { - const response = await fetch("/api/announcement"); - - if (!response.ok) { - console.error("API 응답 실패:", response.status); - throw new Error("공지사항 목록을 불러올 수 없습니다."); - } - - const result = await response.json(); - return result.data || []; - } catch (error) { - console.error("공지사항 조회 오류: ", error); - throw error; - } - } -} - -// ------------------------------------------------------ -// 공지사항 작성 -// ------------------------------------------------------ - -export async function createNotice(data: { - title: string; - is_important: boolean; - content: string; -}): Promise { - // 유효성 검사 - if (!data.title.trim()) { - throw new Error("제목을 입력해주세요."); - } - if (data.title.length > 255) { - throw new Error("제목은 255자를 초과할 수 없습니다."); - } - if (!data.content.trim()) { - throw new Error("내용을 입력해주세요."); - } - - if (USE_MOCK) { - // ------- 로컬스토리지 사용 ------- // - try { - const existingNotices = await getNotices(); - - // 새 공지사항 객체 생성 - const newNotice: Notice = { - id: crypto.randomUUID(), - author_id: "admin", // 변경 필요 (임시 사용자) - title: data.title.trim(), - content: data.content.trim(), - is_important: data.is_important, - created_at: new Date().toISOString(), - updated_at: new Date().toISOString(), - }; - - // 새 공지사항을 목록에 추가 - const updatedNotices = [newNotice, ...existingNotices]; - localStorage.setItem(STORAGE_KEY, JSON.stringify(updatedNotices)); - - return newNotice; - } catch (error) { - console.error("공지사항 작성 오류:", error); - throw new Error("공지사항 작성에 실패했습니다."); - } - } else { - // ------- api route 사용 ------- // - try { - const response = await fetch("/api/announcement", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - title: data.title.trim(), - content: data.content.trim(), - is_important: data.is_important, - author_id: "admin", // 변경 필요 (임시 사용자) - }), - }); - - if (!response.ok) { - const error = await response.json(); - throw new Error(error.error || "공지사항 작성에 실패했습니다."); - } - - const result = await response.json(); - return result.data; - } catch (error) { - console.error("공지사항 작성 오류: ", error); - throw error; - } - } -} - -// ------------------------------------------------------ -// 공지사항 상세 -// ------------------------------------------------------ -export async function getNoticeById(id: string): Promise { - if (USE_MOCK) { - // ------- 로컬스토리지 사용 ------- // - try { - const notices = await getNotices(); - return notices.find((notice) => notice.id === id) || null; - } catch (error) { - console.error("공지사항 조회 오류: ", error); - return null; - } - } else { - // ------- api route 사용 ------- // - try { - const response = await fetch(`/api/announcement?id=${id}`); - - if (!response.ok) { - if (response.status === 404) return null; - throw new Error("공지사항을 불러올 수 없습니다."); - } - - const result = await response.json(); - return result.data || null; - } catch (error) { - console.error("공지사항 조회 오류: ", error); - throw error; - } - } -} diff --git a/lib/projectAPI.ts b/lib/projectAPI.ts deleted file mode 100644 index 18ed1c8..0000000 --- a/lib/projectAPI.ts +++ /dev/null @@ -1,182 +0,0 @@ -import { Timestamp } from "next/dist/server/lib/cache-handlers/types"; - -interface ProjectProps { - projectId?: string; - projectName: string; - type: string; - status: string; - startedAt: Date | undefined; - endedAt: Date | undefined; - techStack: string; - description: string; -} - -interface ProjectMemberProps { - projectId: string | undefined; - userId: string; - email: string; - role: string; -} - -interface ResultProps { - message: string; - params: object; - data?: any[]; - totalCount?: number; - timestamp: Timestamp; -} - -const PROJECT_BASE_URL = 'http://localhost:3000/api/project' -const PROJECT_MEMBER_BASE_URL = 'http://localhost:3000/api/projectMember' - -// Project Info API -export async function getProject(): Promise { - try { - const url = `${PROJECT_BASE_URL}` - const res = await fetch(url); - const data = await res.json(); - - return data; - } catch (err){ - console.log(err); - throw err; - } -} - -export async function getProjectById(id:string): Promise { - try { - const url = `${PROJECT_BASE_URL}?id=${id}` - const res = await fetch(url); - const data = await res.json(); - - return data; - } catch (err){ - console.log(err); - throw err; - } -} - -export async function createProject(projectData: ProjectProps): Promise { - try { - const url = `${PROJECT_BASE_URL}` - const res = await fetch(url, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(projectData), - }); - const data = await res.json(); - - return data - } catch (err){ - console.log(err); - throw err; - } -} - -export async function updateProject(id:string, projectData: ProjectProps): Promise { - try { - const url = `${PROJECT_BASE_URL}?id=${id}` - const res = await fetch(url, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(projectData), - }); - const data = await res.json(); - console.log(data); - - return data - } catch (err){ - console.log(err); - throw err; - } -} - -export async function deleteProject(id:string): Promise { - try { - const url = `${PROJECT_BASE_URL}?id=${id}` - const res = await fetch(url, { - method: 'DELETE' - }); - const data = await res.json(); - - return data; - - } catch (err){ - console.log(err); - throw err; - } -} - -// Project Member API -export async function getProjectMember(id?:string): Promise { - try { - const url = `${PROJECT_MEMBER_BASE_URL}?id=${id}` - const res = await fetch(url); - const data = await res.json(); - - return data; - } catch (err){ - console.log(err); - throw err; - } -} - -export async function addProjectMember(projectMemberData: ProjectMemberProps): Promise { - try { - const url = `${PROJECT_MEMBER_BASE_URL}` - const res = await fetch(url, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(projectMemberData), - }); - const data = await res.json(); - - return data - } catch (err){ - console.log(err); - throw err; - } -} - - -export async function updateProjectMember(id:string, projectMemberData: ProjectMemberProps): Promise { - try { - const url = `${PROJECT_MEMBER_BASE_URL}?id=${id}` - const res = await fetch(url, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(projectMemberData), - }); - const data = await res.json(); - console.log(data); - - return data - } catch (err){ - console.log(err); - throw err; - } -} - -export async function deleteProjectMember(id:string): Promise { - try { - const url = `${PROJECT_MEMBER_BASE_URL}?id=${id}` - const res = await fetch(url, { - method: 'DELETE' - }); - const data = await res.json(); - - return data; - - } catch (err){ - console.log(err); - throw err; - } -} \ No newline at end of file diff --git a/lib/supabase.ts b/lib/supabase.ts deleted file mode 100644 index b106f82..0000000 --- a/lib/supabase.ts +++ /dev/null @@ -1,12 +0,0 @@ -// lib/supabase.ts -import { createClient } from "@supabase/supabase-js"; - -export const supabase = createClient( - process.env.NEXT_PUBLIC_SUPABASE_URL!, - process.env.SUPABASE_SERVICE_ROLE_KEY!, - { - auth: { - persistSession: false, - }, - } // 서버용 -); diff --git a/lib/supabase/client.ts b/lib/supabase/client.ts deleted file mode 100644 index a9cb01b..0000000 --- a/lib/supabase/client.ts +++ /dev/null @@ -1,8 +0,0 @@ -// lib/supabase.ts -import { createClient } from "@supabase/supabase-js"; -import { Database } from "@/app/types/supabase"; - -const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; -const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; - -export const supabase = createClient(supabaseUrl, supabaseAnonKey); diff --git a/lib/toast-style.tsx b/lib/toast-style.tsx deleted file mode 100644 index e0ce11a..0000000 --- a/lib/toast-style.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { Icon } from "@/app/components/Icon/Icon"; - -// 모든 토스트에 공통으로 적용될 기본 클래스 -export const BASE_TOAST_CLASSNAME = ` - !py-2.5 !pl-4 !pr-3 - font-semibold !text-lg !text-white - !shadow-none !m-0 - shadow-sm -`; - -// 모든 토스트에 공통으로 적용될 기본 css -export const BASE_TOAST_STYLE = { - color: "#fff", -}; - -// 타입별 속성 -export const TOAST_COLORS = { - success: { - background: "#79C98D", - }, - error: { - background: "#F26969", - }, - deleted: { - background: "#F26969", - }, - alert: { - background: "#F26969", - }, -}; - -export const ICON_MAP = { - success: , - error: , - deleted: , - alert: , -}; diff --git a/lib/toast.tsx b/lib/toast.tsx deleted file mode 100644 index 71588ce..0000000 --- a/lib/toast.tsx +++ /dev/null @@ -1,48 +0,0 @@ -/** - * 토스트 메시지를 표시합니다. - * @param message 표시할 메시지 - * @param type 토스트 타입 ("success" | "error") - * @example - * showToast("저장되었습니다.", "success") - * showToast("에러가 발생했습니다.", "error") - * showApiError('서버 연결 실패') - * -> /sample/toast/page.tsx - */ - -import toast from "react-hot-toast"; -import { Icon } from "@/app/components/Icon/Icon"; -import { bgColorOpacity } from "@/app/sample/color/page"; -import { - TOAST_COLORS, - BASE_TOAST_STYLE, - BASE_TOAST_CLASSNAME, - ICON_MAP, -} from "./toast-style"; - -type ToastType = "success" | "error" | "deleted" | "alert"; - -export const showToast = (message: string, type: ToastType = "success") => { - const toastColors = TOAST_COLORS[type]; - - const options = { - icon: ICON_MAP[type], - duration: 3000, - - style: { - ...BASE_TOAST_STYLE, - background: toastColors.background, - }, - }; - - if (type === "success") { - toast.success(message, options); - } else if (type === "error" || type === "deleted") { - toast.error(message, options); - } else { - toast(message, options); - } -}; - -export const showApiError = (message: string) => { - showToast(`${message}`, "alert"); -}; diff --git a/lib/userAPI.ts b/lib/userAPI.ts deleted file mode 100644 index 0054c93..0000000 --- a/lib/userAPI.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { Timestamp } from "next/dist/server/lib/cache-handlers/types"; - -interface ResultProps { - message: string; - params: object; - data?: any[]; - count?: number; - timestamp: Timestamp; -} -const USER_BASE_URL = 'http://localhost:3000/api/user/test' - -// Project Info API -export async function getUser(): Promise { - try { - const url = `${USER_BASE_URL}` - const res = await fetch(url); - const data = await res.json(); - - return data; - } catch (err){ - console.log(err); - throw err; - } -} - -export async function getUserById(type:string, id:number): Promise { - try { - const url = `${USER_BASE_URL}?type=${type}&id=${id}` - const res = await fetch(url); - const data = await res.json(); - - return data; - } catch (err){ - console.log(err); - throw err; - } -} \ No newline at end of file diff --git a/lib/utils.ts b/lib/utils.ts deleted file mode 100644 index 8253d1a..0000000 --- a/lib/utils.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { clsx, type ClassValue } from "clsx"; -import { twMerge } from "tailwind-merge"; - -export function cn(...inputs: ClassValue[]) { - return twMerge(clsx(inputs)); -} - -// 날짜 포맷팅 함수 -export const formatDate = (dateString: string) => { - if (!dateString) return "-"; - - try { - const date = new Date(dateString); - return date - .toLocaleDateString("ko-KR", { - year: "numeric", - month: "2-digit", - day: "2-digit", - }) - .replace(/\. /g, "-") - .replace(/\.$/, ""); - } catch { - return dateString.split("T")[0]; - } -}; diff --git a/next-env.d.ts b/next-env.d.ts index c4b7818..9edff1c 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/dev/types/routes.d.ts"; +import "./.next/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/next.config.ts b/next.config.ts index a038014..ed2c8e4 100644 --- a/next.config.ts +++ b/next.config.ts @@ -4,9 +4,6 @@ const nextConfig: NextConfig = { // 개발 중 Fast Refresh 개선 reactStrictMode: true, - typescript: { - ignoreBuildErrors: true, - }, images: { remotePatterns: [ diff --git a/package-lock.json b/package-lock.json index a00447b..251c134 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,7 +20,9 @@ "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-tabs": "^1.1.13", - "@supabase/supabase-js": "^2.81.1", + "@supabase/ssr": "^0.10.2", + "@supabase/supabase-js": "^2.105.1", + "@tanstack/react-query": "^5.100.9", "@tanstack/react-table": "^8.21.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -3021,9 +3023,9 @@ "license": "MIT" }, "node_modules/@supabase/auth-js": { - "version": "2.81.1", - "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.81.1.tgz", - "integrity": "sha512-K20GgiSm9XeRLypxYHa5UCnybWc2K0ok0HLbqCej/wRxDpJxToXNOwKt0l7nO8xI1CyQ+GrNfU6bcRzvdbeopQ==", + "version": "2.105.1", + "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.105.1.tgz", + "integrity": "sha512-zc4s8Xg4truwE1Q4Q8M8oUVDARMd05pKh73NyQsMbYU1HDdDN2iiKzena/yu+yJze3WrD4c092FdckPiK1rLQw==", "license": "MIT", "dependencies": { "tslib": "2.8.1" @@ -3033,9 +3035,9 @@ } }, "node_modules/@supabase/functions-js": { - "version": "2.81.1", - "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.81.1.tgz", - "integrity": "sha512-sYgSO3mlgL0NvBFS3oRfCK4OgKGQwuOWJLzfPyWg0k8MSxSFSDeN/JtrDJD5GQrxskP6c58+vUzruBJQY78AqQ==", + "version": "2.105.1", + "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.105.1.tgz", + "integrity": "sha512-dTk1e7oE51VGc1lS2S0J0NLo0Wp4JYChj74ArJKbIWgoWuFwO0wcJYjeyOV3AAEpKst8/LQWUZOUKO1tRXBrpA==", "license": "MIT", "dependencies": { "tslib": "2.8.1" @@ -3044,10 +3046,16 @@ "node": ">=20.0.0" } }, + "node_modules/@supabase/phoenix": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.1.tgz", + "integrity": "sha512-hWGJkDAfWUNY8k0C080u3sGNFd2ncl9erhKgP7hnGkgJWEfT5Pd/SXal4QmWXBECVlZrannMAc9sBaaRyWpiUA==", + "license": "MIT" + }, "node_modules/@supabase/postgrest-js": { - "version": "2.81.1", - "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.81.1.tgz", - "integrity": "sha512-DePpUTAPXJyBurQ4IH2e42DWoA+/Qmr5mbgY4B6ZcxVc/ZUKfTVK31BYIFBATMApWraFc8Q/Sg+yxtfJ3E0wSg==", + "version": "2.105.1", + "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.105.1.tgz", + "integrity": "sha512-6SbtsoWC55xfsm7gbfLqvF+yIwTQEbjt+jFGf4klDpwSnUy17Hv5x0Dq52oqwTQlw6Ta0h1D5gTP0/pApqNojA==", "license": "MIT", "dependencies": { "tslib": "2.8.1" @@ -3057,12 +3065,12 @@ } }, "node_modules/@supabase/realtime-js": { - "version": "2.81.1", - "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.81.1.tgz", - "integrity": "sha512-ViQ+Kxm8BuUP/TcYmH9tViqYKGSD1LBjdqx2p5J+47RES6c+0QHedM0PPAjthMdAHWyb2LGATE9PD2++2rO/tw==", + "version": "2.105.1", + "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.105.1.tgz", + "integrity": "sha512-3X3cUEl5cJ4lRQHr1hXHx0b98OaL97RRO2vrRZ98FD91JV/MquZHhrGJSv/+IkOnjF6E2e0RUOxE8P3Zi035ow==", "license": "MIT", "dependencies": { - "@types/phoenix": "^1.6.6", + "@supabase/phoenix": "^0.4.1", "@types/ws": "^8.18.1", "tslib": "2.8.1", "ws": "^8.18.2" @@ -3071,12 +3079,38 @@ "node": ">=20.0.0" } }, + "node_modules/@supabase/ssr": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@supabase/ssr/-/ssr-0.10.2.tgz", + "integrity": "sha512-JFbchN63CXLFHJRNT7udec4/RoD9PmXkSGko3QSO6vUuqGBtSzdmxR7FPfQNr7SuFd65I7Xv46q66ALjEN1cgQ==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.2" + }, + "peerDependencies": { + "@supabase/supabase-js": "^2.102.1" + } + }, + "node_modules/@supabase/ssr/node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/@supabase/storage-js": { - "version": "2.81.1", - "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.81.1.tgz", - "integrity": "sha512-UNmYtjnZnhouqnbEMC1D5YJot7y0rIaZx7FG2Fv8S3hhNjcGVvO+h9We/tggi273BFkiahQPS/uRsapo1cSapw==", + "version": "2.105.1", + "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.105.1.tgz", + "integrity": "sha512-owfdCNH5ikXXDusjzsgU6LavEBqGUoueOnL/9XIucld70/WJ/rbqp89K//c9QPICDNuegsmpoeasydDAiucLKQ==", "license": "MIT", "dependencies": { + "iceberg-js": "^0.8.1", "tslib": "2.8.1" }, "engines": { @@ -3084,16 +3118,16 @@ } }, "node_modules/@supabase/supabase-js": { - "version": "2.81.1", - "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.81.1.tgz", - "integrity": "sha512-KSdY7xb2L0DlLmlYzIOghdw/na4gsMcqJ8u4sD6tOQJr+x3hLujU9s4R8N3ob84/1bkvpvlU5PYKa1ae+OICnw==", + "version": "2.105.1", + "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.105.1.tgz", + "integrity": "sha512-4gn6HmsAkCCVU7p8JmgKGhHJ5Btod4ZzSp8qKZf4JHaTxbhaIK86/usHzeLxWv7EJJDhBmILDmJOSOf9iF4CLA==", "license": "MIT", "dependencies": { - "@supabase/auth-js": "2.81.1", - "@supabase/functions-js": "2.81.1", - "@supabase/postgrest-js": "2.81.1", - "@supabase/realtime-js": "2.81.1", - "@supabase/storage-js": "2.81.1" + "@supabase/auth-js": "2.105.1", + "@supabase/functions-js": "2.105.1", + "@supabase/postgrest-js": "2.105.1", + "@supabase/realtime-js": "2.105.1", + "@supabase/storage-js": "2.105.1" }, "engines": { "node": ">=20.0.0" @@ -3379,6 +3413,32 @@ "tailwindcss": "4.1.17" } }, + "node_modules/@tanstack/query-core": { + "version": "5.100.9", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.100.9.tgz", + "integrity": "sha512-SJSFw1S8+kQ0+knv/XGfrbocWoAlT7vDKsSImtLx3ZPQmEcR46hkDjLSvynSy25N8Ms4tIEini1FuBd5k7IscQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.100.9", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.100.9.tgz", + "integrity": "sha512-Oa44XkaI3kCNN6ME0KByU3xT3SEUNOMfZpHxL6+wFoTm+OeUFYHKdeYVe0aOXlRDm/f15sgLwEt2HDorIdW8+A==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.100.9" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, "node_modules/@tanstack/react-table": { "version": "8.21.3", "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz", @@ -3460,12 +3520,6 @@ "undici-types": "~6.21.0" } }, - "node_modules/@types/phoenix": { - "version": "1.6.6", - "resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.6.tgz", - "integrity": "sha512-PIzZZlEppgrpoT2QgbnDU+MMzuR6BbCjllj0bM70lWoejMeNJAxCchxnv7J3XFkI8MpygtRpzXrIlmWUBclP5A==", - "license": "MIT" - }, "node_modules/@types/prop-types": { "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", @@ -6040,6 +6094,15 @@ "hermes-estree": "0.25.1" } }, + "node_modules/iceberg-js": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz", + "integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -9308,9 +9371,9 @@ } }, "node_modules/ws": { - "version": "8.18.3", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", - "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", "license": "MIT", "engines": { "node": ">=10.0.0" diff --git a/package.json b/package.json index 16dba2f..baec578 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,9 @@ "dev": "next dev", "build": "next build", "start": "next start", - "lint": "eslint" + "lint": "eslint", + "typecheck": "tsc --noEmit", + "typecheck": "tsc --noEmit" }, "dependencies": { "@dnd-kit/core": "^6.3.1", @@ -21,7 +23,9 @@ "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-tabs": "^1.1.13", - "@supabase/supabase-js": "^2.81.1", + "@supabase/ssr": "^0.10.2", + "@supabase/supabase-js": "^2.105.1", + "@tanstack/react-query": "^5.100.9", "@tanstack/react-table": "^8.21.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/src/app/(admin)/admin/page.tsx b/src/app/(admin)/admin/page.tsx index ac00858..37fc22c 100644 --- a/src/app/(admin)/admin/page.tsx +++ b/src/app/(admin)/admin/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect } from "react"; +import { Suspense, useEffect } from "react"; import { SectionHeader } from "@/components/shared/SectionHeader"; import { useRouter } from "next/navigation"; import { useSearchParams } from "next/navigation"; @@ -10,7 +10,7 @@ import AdminProjectsPage from "./projects/page"; import AdminNoticesPage from "./notice/page"; import AdminUsersPage from "./users/page"; -export default function Page() { +function AdminContent() { const router = useRouter(); const searchParams = useSearchParams(); const activeTab = searchParams.get("tab") || "users"; @@ -67,3 +67,11 @@ export default function Page() { ); } + +export default function Page() { + return ( + + + + ); +} diff --git a/src/app/(admin)/admin/users/page.tsx b/src/app/(admin)/admin/users/page.tsx index a4ed3dc..79a37b2 100644 --- a/src/app/(admin)/admin/users/page.tsx +++ b/src/app/(admin)/admin/users/page.tsx @@ -1,4 +1,4 @@ -"Use Client"; +"use client"; import Button from "@/components/ui/Button"; import AdminPageWrapper from "@/components/features/admin/AdminPageWrapper"; import { primaryBgColor } from "@/app/sample/color/page"; diff --git a/src/app/(auth)/login/components/ProfileModal.tsx b/src/app/(auth)/login/components/ProfileModal.tsx index 2be44e3..f8d830f 100644 --- a/src/app/(auth)/login/components/ProfileModal.tsx +++ b/src/app/(auth)/login/components/ProfileModal.tsx @@ -1,6 +1,7 @@ import Button from "@/components/ui/Button"; import { Icon } from "@/components/shared/Icon"; import { signOut } from "next-auth/react"; +import Image from "next/image"; export interface ProfileModalProps { isOpen: boolean; @@ -32,10 +33,12 @@ export default function ProfileModal({ onClose, user }: ProfileModalProps) { {/* 유저 정보 */}
- profile

{user?.name}

diff --git a/src/app/(auth)/login/page.tsx b/src/app/(auth)/login/page.tsx index 125678e..a7c627e 100644 --- a/src/app/(auth)/login/page.tsx +++ b/src/app/(auth)/login/page.tsx @@ -3,10 +3,10 @@ import Button from "@/components/ui/Button"; import { Icon } from "@/components/shared/Icon"; import { signIn } from "next-auth/react"; import { useSearchParams } from "next/navigation"; -import { useEffect } from "react"; +import { useEffect, Suspense } from "react"; import Container from "@/components/shared/Container"; -export default function LoginPage() { +function LoginContent() { const searchParams = useSearchParams(); useEffect(() => { @@ -17,7 +17,6 @@ export default function LoginPage() { localStorage.setItem("invite_id", inviteId); } - console.log(inviteId,"inviteId") }, [searchParams]); return ( @@ -56,3 +55,11 @@ export default function LoginPage() { ); } + +export default function LoginPage() { + return ( + + + + ); +} diff --git a/src/app/(main)/notice/layout.tsx b/src/app/(main)/notice/layout.tsx index 216f7dd..53813e2 100644 --- a/src/app/(main)/notice/layout.tsx +++ b/src/app/(main)/notice/layout.tsx @@ -1,8 +1,8 @@ "use client"; -import { useEffect } from "react"; +import React, { useEffect } from "react"; -export default function NoticesLayout({ children }) { +export default function NoticesLayout({ children }: { children: React.ReactNode }) { useEffect(() => { // 공지사항 페이지 진입 시 스크롤 활성화 document.body.classList.remove("overflow-hidden", "h-full"); diff --git a/src/app/(main)/project/workspace/page.tsx b/src/app/(main)/project/workspace/page.tsx index e6445c8..64bce82 100644 --- a/src/app/(main)/project/workspace/page.tsx +++ b/src/app/(main)/project/workspace/page.tsx @@ -5,10 +5,11 @@ // React Hooks - 상태 관리 및 생명주기 관리 import { useEffect, useState } from "react"; import { useRouter } from "next/navigation"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import dynamic from "next/dynamic"; +import { queryKeys } from "@/lib/constants/queryKeys"; // 메인 기능 컴포넌트들 - 칸반보드, 캘린더, 네비게이션 -import CalendarView from "@/components/features/calendarView/CalendarView"; -import KanbanBoard from "@/components/features/kanban/KanbanBoard"; import BottomNavigation from "@/components/layout/BottomNavigation"; // 타입 정의 및 유틸리티 @@ -30,11 +31,16 @@ import { useSession } from "next-auth/react"; import { supabase } from "@/lib/supabase/supabase"; import { ProjectRole } from "@/types"; -// 메모 기능 컴포넌트 - 실시간 협업 메모 -import MemoView from "@/components/features/kanban/MemoView"; - -// 프로젝트 정보 패널 -import ProjectInfoPanel from "@/components/features/project/ProjectInfoPanel"; +const CalendarView = dynamic( + () => import("@/components/features/calendarView/CalendarView") +); +const KanbanBoard = dynamic( + () => import("@/components/features/kanban/KanbanBoard") +); +const MemoView = dynamic(() => import("@/components/features/kanban/MemoView")); +const ProjectInfoPanel = dynamic( + () => import("@/components/features/project/ProjectInfoPanel") +); // 네비게이션 타입 정의 - 하단 탭 네비게이션용 type NavItem = "calendar" | "kanban" | "memo" | "project"; @@ -55,24 +61,71 @@ export default function ProjectPage() { // const projectId = params.id as string; const router = useRouter(); + const { data: session } = useSession(); + const queryClient = useQueryClient(); // === 핵심 상태 관리 === const [projectId, setProjectId] = useState(""); // sessionStorage에서 가져올 프로젝트 ID - const [projectName, setProjectName] = useState(""); // 프로젝트 이름 (헤더 표시용) - const [projectStartDate, setProjectStartDate] = useState(""); // 프로젝트 시작일 (D-day 계산용) - const [projectEndDate, setProjectEndDate] = useState(""); // 프로젝트 종료일 (D-day 계산용) - const [kanbanBoardId, setKanbanBoardId] = useState(""); // 칸반보드 ID (실시간 구독용)h - const [tasks, setTasks] = useState([]); // 태스크 목록 (실시간 동기화) + const [kanbanBoardId, setKanbanBoardId] = useState(""); // 칸반보드 ID (실시간 구독용) // === UI 상태 관리 === const [currentView, setCurrentView] = useState("kanban"); // 메인 뷰 (칸반/캘린더) const [showMemoPanel, setShowMemoPanel] = useState(false); // 메모 패널 토글 상태 const [showProjectInfoPanel, setShowProjectInfoPanel] = useState(false); // 프로젝트 정보 패널 토글 상태 - const [loading, setLoading] = useState(true); // 초기 데이터 로딩 상태 - // === 권한 관리 === - const [userRole, setUserRole] = useState(null); // 사용자 역할 (leader/member) - const { data: session } = useSession(); // NextAuth 세션 정보 + const userId = session?.user?.user_id; + const taskQueryKey = queryKeys.tasks.list(projectId); + + // === 프로젝트 정보 조회 === + const { data: projectInfo } = useQuery({ + queryKey: queryKeys.workspace.info(projectId), + queryFn: async () => { + const res = await fetch(`/api/projects/${projectId}`); + if (!res.ok) return { project_name: "알 수 없는 프로젝트", started_at: "", ended_at: "" }; + return res.json(); + }, + enabled: !!projectId, + staleTime: 1000 * 60 * 5, + }); + + // === 태스크 목록 조회 === + const { data: tasks = [], isLoading: tasksLoading } = useQuery({ + queryKey: taskQueryKey, + queryFn: async () => { + const { data, error } = await getTasksByBoardId(projectId); + if (error) throw error; + return data || []; + }, + enabled: !!projectId, + staleTime: 0, + }); + + // === 사용자 역할 조회 === + const { data: userRole = null } = useQuery({ + queryKey: queryKeys.workspace.role(projectId, userId), + queryFn: async () => { + if (!userId || !projectId) return null; + const { data, error } = await supabase + .from("project_members") + .select("role") + .eq("project_id", projectId) + .eq("user_id", userId) + .maybeSingle(); + if (error) { + console.error("프로젝트 멤버 역할 조회 오류:", error); + return null; + } + return (data?.role as ProjectRole) ?? null; + }, + enabled: !!projectId && !!userId, + staleTime: 1000 * 60 * 5, + }); + + // 파생 값 + const projectName = projectInfo?.project_name || "이름 없는 프로젝트"; + const projectStartDate = projectInfo?.started_at || ""; + const projectEndDate = projectInfo?.ended_at || ""; + const loading = tasksLoading; /** * 🔄 워크플로우 제어: sessionStorage 기반 접근 관리 @@ -99,142 +152,39 @@ export default function ProjectPage() { }, [router]); /** - * 👤 사용자 역할 기반 인가 시스템 - * - * 역할별 권한: - * - Leader: 팀원 초대, 프로젝트 설정 변경, 모든 태스크 관리 - * - Member: 태스크 생성/수정, 메모 작성, 읽기 권한 - * - * 현재 구현 상태: - * - 클라이언트 사이드 UI 제어만 구현됨 (기본적인 사용자 경험 제어) - * - Supabase Row Level Security 및 서버사이드 권한 검증은 미구현 상태 - */ - useEffect(() => { - const fetchRole = async () => { - if (!session?.user?.user_id || !projectId) return; - - // project_members 테이블에서 현재 사용자의 역할 조회 - const { data, error } = await supabase - .from("project_members") - .select("role") - .eq("project_id", projectId) - .eq("user_id", session.user.user_id) - .maybeSingle(); - - if (error) { - console.error("프로젝트 멤버 역할 조회 오류:", error); - return; - } - - if (data) setUserRole(data.role as ProjectRole); - }; - fetchRole(); - }, [projectId, session?.user?.user_id]); - - /** - * 📊 프로젝트 데이터 통합 로딩 + 칸반보드 자동 생성 - * - * 로딩 순서: - * 1. 프로젝트 정보 조회 - * 2. 칸반보드 존재 확인 → 없으면 자동 생성 (레거시 호환) - * 3. 태스크 목록 조회 - * - * 자동 생성 이유: - * - 기존 프로젝트는 칸반보드 없이 생성됨 - * - 무중단 마이그레이션을 위한 호환 로직 + * � 칸반보드 초기화 (최초 1회 자동 생성 포함) + * - 프로젝트 정보/태스크는 useQuery로 이동 + * - 보드 자동 생성은 조건부 mutation이라 useEffect 유지 */ useEffect(() => { - // 세션에서 가져온 projectId가 있을 때만 데이터 로딩 실행 - if (!projectId) return; - - const fetchData = async () => { - try { - // projectId 유효성 검사 - if (!projectId || projectId === "undefined" || projectId === "null") { - console.warn("⚠️ Invalid projectId:", projectId); - setLoading(false); - return; - } - - // 1. 프로젝트 정보 가져오기 - API Route 사용 - const projectRes = await fetch(`/api/projects/${projectId}`); - - if (projectRes.ok) { - const projectData = await projectRes.json(); - setProjectName(projectData.project_name || "이름 없는 프로젝트"); - setProjectStartDate(projectData.started_at || ""); - setProjectEndDate(projectData.ended_at || ""); - } else { - setProjectName("알 수 없는 프로젝트"); - setProjectStartDate(""); - setProjectEndDate(""); - } - - // 2. 칸반보드 ID 가져오기 (또는 생성) - API Route 사용 - let boardId = null; - - // 기존 칸반보드 조회 - const kanbanRes = await fetch( - `/api/kanban/boards?projectId=${projectId}` - ); - - if (kanbanRes.ok) { - const kanbanData = await kanbanRes.json(); - - if (kanbanData && kanbanData.length > 0) { - // 이미 칸반보드가 있는 경우 - boardId = kanbanData[0].id; - } else { - /** - * 🎯 칸반보드 자동 생성 (레거시 호환성) - * - * 이유: - * - 초기 프로젝트들은 칸반보드 없이 생성됨 - * - 사용자가 처음 워크스페이스 접속 시 자동으로 생성 - * - 표준 워크플로우 강제: todo → inprogress → done - */ - console.log("⚠️ 칸반보드가 없어서 새로 생성합니다."); - - const createRes = await fetch("/api/kanban/boards", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - project_id: projectId, - columns: "todo,inprogress,done", // MVP: 표준 컬럼 구조 고정 - }), - }); - - if (createRes.ok) { - const newKanban = await createRes.json(); - boardId = newKanban.id; - } else { - console.error("칸반보드 생성 실패"); - } - } - } - - if (boardId) { - setKanbanBoardId(boardId); + if (!projectId || projectId === "undefined" || projectId === "null") return; + + const initKanbanBoard = async () => { + const kanbanRes = await fetch(`/api/kanban/boards?projectId=${projectId}`); + if (!kanbanRes.ok) return; + + const kanbanData = await kanbanRes.json(); + + if (kanbanData && kanbanData.length > 0) { + setKanbanBoardId(kanbanData[0].id); + } else { + // 레거시 프로젝트: 칸반보드 자동 생성 + const createRes = await fetch("/api/kanban/boards", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + project_id: projectId, + columns: "todo,inprogress,done", + }), + }); + if (createRes.ok) { + const newKanban = await createRes.json(); + setKanbanBoardId(newKanban.id); } - - // 3. Tasks 가져오기 - const { data: tasksData, error: tasksError } = await getTasksByBoardId( - projectId - ); - - if (tasksError) { - console.error("Tasks 조회 실패:", tasksError); - } else { - setTasks(tasksData || []); - } - } catch (error) { - console.error("데이터 로딩 중 오류:", error); - } finally { - setLoading(false); } }; - fetchData(); + initKanbanBoard(); }, [projectId]); /** @@ -252,7 +202,6 @@ export default function ProjectPage() { */ useEffect(() => { if (!projectId || !kanbanBoardId) return; - console.log("리얼타임 업데이트 설정 실행"); // 칸반보드별 채널 생성 (네임스페이스 분리) const channel = supabase @@ -266,10 +215,8 @@ export default function ProjectPage() { filter: `kanban_board_id=eq.${kanbanBoardId}`, // 현재 보드의 태스크만 }, (payload) => { - console.log("리얼타임 업데이트 수신:", payload.eventType, payload); - if (payload.eventType === "INSERT") { - const newTaskRaw = payload.new as any; + const newTaskRaw = payload.new as Task; // 🔄 담당자 정보 추가 조회 (Realtime에는 JOIN 데이터 없음) const enrichTask = async () => { @@ -296,20 +243,16 @@ export default function ProjectPage() { assignee, } as Task; - setTasks((prev) => { - // 🛡️ 중복 추가 방지 (방어적 프로그래밍) - if (prev.some((t) => t.id === enrichedTask.id)) { - console.log("이미 존재하는 Task"); - return prev; - } - console.log("새로운 Task 추가:", enrichedTask.title); + queryClient.setQueryData(taskQueryKey, (prev: Task[]) => { + if (!prev) return [enrichedTask]; + if (prev.some((t) => t.id === enrichedTask.id)) return prev; return [...prev, enrichedTask]; }); }; enrichTask(); } else if (payload.eventType === "UPDATE") { - const updatedTaskRaw = payload.new as any; + const updatedTaskRaw = payload.new as Task; // 🔄 담당자 정보 추가 조회 (UPDATE 시에도 필요) const enrichUpdateTask = async () => { @@ -336,132 +279,103 @@ export default function ProjectPage() { assignee, } as Task; - setTasks((prev) => - prev.map((t) => (t.id === enrichedTask.id ? enrichedTask : t)) + queryClient.setQueryData(taskQueryKey, (prev: Task[]) => + (prev || []).map((t) => (t.id === enrichedTask.id ? enrichedTask : t)) ); }; enrichUpdateTask(); } else if (payload.eventType === "DELETE") { const deletedTask = payload.old as Task; - setTasks((prev) => prev.filter((t) => t.id !== deletedTask.id)); - console.log("Task 삭제:", deletedTask.title); + queryClient.setQueryData(taskQueryKey, (prev: Task[]) => + (prev || []).filter((t) => t.id !== deletedTask.id) + ); } } ) - .subscribe((status) => { - console.log("Supabase 채널 상태:", status); - }); + .subscribe(); // 🧹 컴포넌트 언마운트 시 채널 정리 (메모리 누수 방지) return () => { - console.log("Supabase 채널 해제"); supabase.removeChannel(channel); }; - }, [projectId, kanbanBoardId]); - - /** - * 📝 Task 생성 핸들러 - * - * 하이브리드 패턴: - * - DB 업데이트 후 즉시 로컬 상태 업데이트 (빠른 피드백) - * - Realtime은 다른 사용자의 변경사항 동기화용 - */ - const handleCreateTask = async ( - taskData: Omit - ) => { - const { data, error } = await createTask(taskData); - - if (error) { - showToast("작업 생성 실패", "error"); - return; - } + }, [projectId, kanbanBoardId, queryClient, taskQueryKey]); + + // === Task CRUD (useMutation) === + const createTaskMutation = useMutation({ + mutationFn: async (taskData: Omit) => { + const { data, error } = await createTask(taskData); + if (error) throw error; + return data; + }, + onSuccess: (data) => { + if (data) { + queryClient.setQueryData(taskQueryKey, (prev: Task[]) => { + if ((prev || []).some((t) => t.id === data.id)) return prev; + return [...(prev || []), data]; + }); + showToast("작업이 생성되었습니다.", "success"); + } + }, + onError: () => showToast("작업 생성 실패", "error"), + }); + + const updateTaskMutation = useMutation({ + mutationFn: async ({ taskId, updates }: { taskId: string; updates: Partial }) => { + const { data, error } = await updateTask(taskId, updates); + if (error) throw error; + return { taskId, updates }; + }, + onSuccess: ({ taskId, updates }) => { + queryClient.setQueryData(taskQueryKey, (prev: Task[]) => + (prev || []).map((t) => (t.id === taskId ? { ...t, ...updates } : t)) + ); + }, + }); + + const deleteTaskMutation = useMutation({ + mutationFn: async (taskId: string) => { + const { error } = await deleteTask(taskId); + if (error) throw error; + return taskId; + }, + onSuccess: (taskId) => { + queryClient.setQueryData(taskQueryKey, (prev: Task[]) => + (prev || []).filter((t) => t.id !== taskId) + ); + showToast("작업이 삭제되었습니다.", "success"); + }, + onError: () => showToast("작업 삭제 실패", "error"), + }); - if (data) { - // 즉시 로컬 상태 업데이트 (낙관적 업데이트) - setTasks((prev) => { - // 중복 방지 - if (prev.some((t) => t.id === data.id)) return prev; - return [...prev, data]; - }); - showToast("작업이 생성되었습니다.", "success"); - } + const handleCreateTask = (taskData: Omit) => { + createTaskMutation.mutate(taskData); }; - /** - * ✏️ Task 수정 핸들러 - * - * 하이브리드 패턴: - * - 서버 업데이트 후 즉시 로컬 상태 반영 - * - Realtime은 다른 사용자의 변경사항 동기화용 - */ - const handleUpdateTask = async (taskId: string, updates: Partial) => { - const { data, error } = await updateTask(taskId, updates); - - if (!error && data) { - // 즉시 로컬 상태 업데이트 - setTasks((prev) => - prev.map((t) => (t.id === taskId ? { ...t, ...updates } : t)) - ); - } + const handleUpdateTask = (taskId: string, updates: Partial) => { + updateTaskMutation.mutate({ taskId, updates }); }; - /** - * 🗑️ Task 삭제 핸들러 - * - * 하이브리드 패턴: - * - 서버 삭제 성공 시 즉시 로컬 상태 반영 - * - Realtime은 다른 사용자의 변경사항 동기화용 - */ - const handleDeleteTask = async (taskId: string) => { - const { error } = await deleteTask(taskId); - - if (error) { - showToast("작업 삭제 실패", "error"); - return; - } - - // 즉시 로컬 상태 업데이트 - setTasks((prev) => prev.filter((t) => t.id !== taskId)); - showToast("작업이 삭제되었습니다.", "success"); + const handleDeleteTask = (taskId: string) => { + deleteTaskMutation.mutate(taskId); }; - const handleRefresh = async () => { - const { data: tasksData, error: tasksError } = await getTasksByBoardId( - projectId - ); - - if (tasksError) { - console.error("Tasks 조회 실패:", tasksError); - } else { - setTasks(tasksData || []); - } + const handleRefresh = () => { + queryClient.invalidateQueries({ queryKey: taskQueryKey }); }; /** * 🧭 스마트 뷰 전환 + 메모 패널 토글 - * - * UX 설계: - * - 메모: 사이드 패널 토글 (칸반/캘린더와 함께 보기) - * - 칸반/캘린더: 메인 뷰 전환 (전체 화면) - * - 프로젝트 종료: 완전 나가기 + 상태 정리 - * - * 일관성 고려: - * - 메모는 "보조" 기능 → 토글 방식 - * - 칸반/캘린더는 "메인" 기능 → 배타적 전환 */ const handleViewChange = (view: NavItem) => { if (view === "memo") { - // 메모 패널 토글 (기존 뷰와 함께 표시) setShowMemoPanel((prev) => !prev); } else if (view === "project") { - // 🧹 프로젝트 종료 시 세션 스토리지 정리 (상태 초기화) sessionStorage.removeItem("current_Project_Id"); window.location.href = "/"; } else { - // 메인 뷰 전환 (칸반 ↔ 캘린더) setCurrentView(view); - setShowMemoPanel(false); // 메모 패널은 자동으로 닫기 + setShowMemoPanel(false); } }; diff --git a/src/app/api/admin/invitations/route.ts b/src/app/api/admin/invitations/route.ts index e9c1c8d..ec2ae12 100644 --- a/src/app/api/admin/invitations/route.ts +++ b/src/app/api/admin/invitations/route.ts @@ -1,8 +1,5 @@ import { NextResponse } from "next/server"; import { supabaseAdmin } from "@/lib/supabase/server"; -import { Resend } from "resend"; - -export const resend = new Resend(process.env.RESEND_API_KEY); export async function GET() { try { @@ -45,7 +42,7 @@ const formatted = data.map((row) => ({ status: row.status, created_at: row.created_at, updated_at: row.updated_at, - project_name: row.projects?.project_name ?? null, + project_name: (row.projects as unknown as { project_name: string } | null)?.project_name ?? null, })); @@ -160,33 +157,29 @@ export async function POST(req: Request) { - // 3. 이메일 발송 NEXT_PUBLIC_APP_URL: resend 설정 url + // 3. 이메일 발송 (Supabase Auth 초대 이메일) const inviteUrl = `${process.env.NEXT_PUBLIC_APP_URL}/login?invite=${invitationId}`; - - //reuslt는 resend 에서 메일 발송에 대한 ex :"id": "3788f0cf-fcee-4b10-8dcb-23bcd428569b" 만 전달받는다. - //이메일 발송자체를 추적할거아니면 딱히 필요하지않음. - const result= await resend.emails.send({ - from: "Taskry ", - to: email, - subject: - invitation_type === "project" - ? "프로젝트에 초대되었습니다" - : "서비스에 초대되었습니다", - html: ` -

초대 안내

-

아래 버튼을 눌러 로그인하여 초대를 완료하세요.

- - 초대 수락하기 - - `, + const { error: inviteError } = await supabaseAdmin.auth.admin.inviteUserByEmail(email, { + redirectTo: inviteUrl, }); + if (inviteError) { + console.error("이메일 발송 오류:", inviteError); + // 이메일 발송 실패 시 초대 레코드 삭제 + await supabaseAdmin + .from("project_invitation_new") + .delete() + .eq("invitation_id", invitationId); + return NextResponse.json( + { error: "이메일 발송 실패" }, + { status: 500 } + ); + } + return NextResponse.json({ ok: true, invitationId, - result }); } catch (err) { console.error(err); diff --git a/src/app/api/announcements/route.ts b/src/app/api/announcements/route.ts index bef67fa..23c9e69 100644 --- a/src/app/api/announcements/route.ts +++ b/src/app/api/announcements/route.ts @@ -9,7 +9,7 @@ import { supabaseAdmin } from "@/lib/supabase/server"; // 각 API에서 반복되는 try, catch 로직을 줄이기 위함 // ------------------------------------------------------ -async function handleRequest(fn: () => Promise) { +async function handleRequest(fn: () => Promise) { try { return await fn(); } catch (error) { diff --git a/src/app/api/auth/[...nextauth]/route.ts b/src/app/api/auth/[...nextauth]/route.ts index c477fca..840a715 100644 --- a/src/app/api/auth/[...nextauth]/route.ts +++ b/src/app/api/auth/[...nextauth]/route.ts @@ -1,6 +1,6 @@ import NextAuth, { NextAuthOptions } from "next-auth"; import GoogleProvider from "next-auth/providers/google"; -import { supabase } from "@/lib/supabase/supabase"; +import { supabaseAdmin } from "@/lib/supabase/server"; export const authOptions: NextAuthOptions = { providers: [ @@ -16,80 +16,78 @@ export const authOptions: NextAuthOptions = { callbacks: { async signIn({ user }) { - // const supabase = supabaseServer; - const email = user.email; - + const email = user.email?.trim().toLowerCase(); if (!email) return false; - // 기존 유저 조회 - const { data: existingUser } = await supabase - .from("users") - .select("*") - .eq("email", email) - .single(); + const now = new Date().toISOString(); + const userPayload = { + user_name: user.name, + profile_image: user.image, + updated_at: now, + is_active: true, + auth_provider: "google", + }; + + try { + // maybeSingle avoids treating "no row" as an exception path. + const { data: existingUser, error: existingUserError } = await supabaseAdmin + .from("users") + .select("user_id, global_role") + .eq("email", email) + .maybeSingle(); + + if (existingUserError) { + console.error("[auth] failed to fetch user", existingUserError); + return false; + } - // 기존 유저가 있고, global_role === 'admin'이면 → 관리자 정보만 업데이트만 - if (existingUser?.global_role === "admin") { - console.log("관리자입니다. "); + if (!existingUser) { + const { error: insertError } = await supabaseAdmin.from("users").insert({ + email, + global_role: "user", + ...userPayload, + }); + + if (insertError) { + console.error("[auth] failed to create user", insertError); + return false; + } + + return true; + } - await supabase + const { error: updateError } = await supabaseAdmin .from("users") - .update({ - user_name: user.name, - profile_image: user.image, - updated_at: new Date().toISOString(), - is_active: true, - auth_provider: "google", - }) + .update(userPayload) .eq("email", email); - return true; - } - - //신규 유저라면 INSERT - if (!existingUser) { - console.log("신규유저입니다. "); - await supabase.from("users").insert({ - email: user.email, - user_name: user.name, - profile_image: user.image, - // password: null, // 소셜 로그인 → 사용 X - global_role: "user", // 일반 유저 - auth_provider: "google", - is_active: true, - updated_at: new Date().toISOString(), - }); + if (updateError) { + console.error("[auth] failed to update user", updateError); + return false; + } return true; + } catch (error) { + console.error("[auth] unexpected signIn error", error); + return false; } - - //기존 일반 유저라면 UPDATE - await supabase - .from("users") - .update({ - user_name: user.name, - profile_image: user.image, - updated_at: new Date().toISOString(), - is_active: true, - auth_provider: "google", - }) - .eq("email", email); - - console.log("기존유저입니다. "); - return true; }, async jwt({ token, user }) { //처음 토큰 생성한 뒤 payload에 유저정보를 브라우저에 세션으로 넘겨주기위한 코드 // 그 뒤 토큰 검증할때마다 실행될때는 user가 없기때문에 하위 코드는 실행되지않는다. - if (user) { - // DB에서 유저 조회 - // const supabase = supabaseServer; - const { data: existingUser } = await supabase + if (user?.email) { + const email = user.email.trim().toLowerCase(); + const { data: existingUser, error: existingUserError } = await supabaseAdmin .from("users") .select("user_id, global_role, user_name, email, profile_image") - .eq("email", user.email) - .single(); + .eq("email", email) + .maybeSingle(); + + if (existingUserError) { + console.error("[auth] failed to load user for jwt", existingUserError); + return token; + } if (existingUser) { token.user_id = existingUser.user_id; @@ -115,7 +113,6 @@ export const authOptions: NextAuthOptions = { }, }, - secret: process.env.NEXTAUTH_SECRET, }; diff --git a/src/app/api/projectMemos/route.ts b/src/app/api/projectMemos/route.ts index 82fbe05..dddf198 100644 --- a/src/app/api/projectMemos/route.ts +++ b/src/app/api/projectMemos/route.ts @@ -1,6 +1,15 @@ import { supabase } from "@/lib/supabase/supabase"; import { getServerSession } from "next-auth/next"; import { authOptions } from "@/app/api/auth/[...nextauth]/route"; +import type { ProjectMemo } from "@/types/projectMemo"; + +// ============================================ +// 타입 +// ============================================ +interface ApiError { + error: string; + status: number; +} // ============================================ // 유틸 함수 @@ -45,7 +54,7 @@ async function getMemoById(memoId: string) { /** * 작성자 권한 확인 */ -function checkAuthor(memo: any, userId: string) { +function checkAuthor(memo: ProjectMemo, userId: string) { if (memo.user_id !== userId) { throw { error: "권한이 없습니다 (작성자만 가능)", @@ -57,9 +66,15 @@ function checkAuthor(memo: any, userId: string) { /** * 에러 응답 생성 */ -function errorResponse(error: any, defaultMessage: string) { - if (error.error && error.status) { - return Response.json({ error: error.error }, { status: error.status }); +function errorResponse(error: unknown, defaultMessage: string) { + if ( + error && + typeof error === "object" && + "error" in error && + "status" in error + ) { + const apiError = error as ApiError; + return Response.json({ error: apiError.error }, { status: apiError.status }); } console.error("API error:", error); diff --git a/src/app/api/tasks/tasks.ts b/src/app/api/tasks/tasks.ts index c13b733..836a34d 100644 --- a/src/app/api/tasks/tasks.ts +++ b/src/app/api/tasks/tasks.ts @@ -23,7 +23,7 @@ const DB_TASK_FIELDS = [ */ type ApiResponse = { data: T | null; - error: any; + error: Error | null; }; /** @@ -50,9 +50,10 @@ function sanitizeTaskData>( /** * 에러 핸들링 헬퍼 */ -function handleApiError(operation: string, error: any): ApiResponse { - console.error(`${operation} 실패:`, error); - return { data: null, error }; +function handleApiError(operation: string, error: unknown): ApiResponse { + const err = error instanceof Error ? error : new Error(String(error)); + console.error(`${operation} 실패:`, err); + return { data: null, error: err }; } /** diff --git a/src/app/data/mockTasks.ts b/src/app/data/mockTasks.ts index 23e3246..44d64a3 100644 --- a/src/app/data/mockTasks.ts +++ b/src/app/data/mockTasks.ts @@ -1,7 +1,6 @@ // Mock 데이터 - 실제 DB 없이 테스트용 -import { Task } from "../types/kanban"; - -export const mockTasks: Task[] = [ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const mockTasks: any[] = [ // 프로젝트 1: project01 { id: "1", diff --git a/src/app/page.tsx b/src/app/page.tsx index ee3b529..1b17172 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -9,8 +9,6 @@ import Container from "@/components/shared/Container"; import ProjectBoard from "@/components/features/project/ProjectBoard"; const Home = () => { - console.log("프로젝트 목록페이지"); - const [inviteData, setInviteData] = useState(null); useEffect(() => { diff --git a/src/app/sample/modal/page.tsx b/src/app/sample/modal/page.tsx index 75beceb..f176903 100644 --- a/src/app/sample/modal/page.tsx +++ b/src/app/sample/modal/page.tsx @@ -6,7 +6,7 @@ import { useModal } from "@/hooks/useModal"; export default function Page() { type buttonType = { - type?: string; + type?: "error" | "delete" | "success" | "progress" | "deleteSuccess"; title?: string; description?: string; warning?: string; @@ -48,7 +48,7 @@ export default function Page() { key={button.name} onClick={() => openModal( - button.type, + button.type!, button.title, button.description, button.warning diff --git a/src/app/sample/skeleton/page.tsx b/src/app/sample/skeleton/page.tsx index 90b2796..6c09a61 100644 --- a/src/app/sample/skeleton/page.tsx +++ b/src/app/sample/skeleton/page.tsx @@ -1,6 +1,6 @@ import Container from "@/components/shared/Container"; -import { ProjectCardSkeleton } from "@/components/ui/ProjectCardSkeleton"; -import { UserTableSkeleton } from "@/components/ui/UserTableSkeleton"; +import ProjectCardSkeleton from "@/components/ui/ProjectCardSkeleton"; +import UserTableSkeleton from "@/components/ui/UserTableSkeleton"; export default function Page() { return ( diff --git a/src/app/sample/table/page.tsx b/src/app/sample/table/page.tsx index 77d30a4..25a8ff3 100644 --- a/src/app/sample/table/page.tsx +++ b/src/app/sample/table/page.tsx @@ -1,7 +1,7 @@ "use client"; import { useState } from "react"; -import CommonTable, { TableColumn } from "@/components/ui/Commontable"; +import CommonTable, { TableColumn } from "@/components/ui/CommonTable"; import { Button } from "@/components/ui/shadcn/Button"; import Container from "@/components/shared/Container"; diff --git a/src/components/features/calendarView/CalendarView.tsx b/src/components/features/calendarView/CalendarView.tsx index bc9dcf0..30171df 100644 --- a/src/components/features/calendarView/CalendarView.tsx +++ b/src/components/features/calendarView/CalendarView.tsx @@ -10,8 +10,8 @@ import { Task, TaskPriority } from "@/types/kanban"; // 컴포넌트 import Modal from "@/components/ui/Modal"; -import TaskAdd from "@/components/features/task/add/TaskAdd"; -import TaskDetail from "@/components/features/task/detail/TaskDetail"; +import { TaskAdd } from "@/features/task"; +import { TaskDetail } from "@/features/task"; import CalendarHeader from "@/components/features/calendarView/components/CalendarHeader"; import CalendarHelp from "@/components/features/calendarView/components/CalendarHelp"; import CalendarStats from "@/components/features/calendarView/components/CalendarStats"; diff --git a/src/components/features/kanban/KanbanBoard.tsx b/src/components/features/kanban/KanbanBoard.tsx index fc14f3f..845a7a8 100644 --- a/src/components/features/kanban/KanbanBoard.tsx +++ b/src/components/features/kanban/KanbanBoard.tsx @@ -15,10 +15,10 @@ import { import { KANBAN_COLUMNS } from "@/lib/constants"; import { Task, TaskStatus } from "@/types"; import KanbanColumn from "@/components/features/kanban/KanbanColumn"; -import TaskCard from "@/components/features/task/card/TaskCard"; +import TaskCard from "@/features/task/ui/card/TaskCard"; import Modal from "@/components/ui/Modal"; -import TaskDetail from "@/components/features/task/detail/TaskDetail"; -import TaskAdd from "@/components/features/task/add/TaskAdd"; +import TaskDetail from "@/features/task/ui/detail/TaskDetail"; +import TaskAdd from "@/features/task/ui/add/TaskAdd"; import KanbanLayout from "@/components/layout/KanbanLayout"; import { showToast } from "@/lib/utils/toast"; import KanbanHeader from "./components/KanbanHeader"; diff --git a/src/components/features/kanban/KanbanColumn.tsx b/src/components/features/kanban/KanbanColumn.tsx index 52f68e1..96bc7b2 100644 --- a/src/components/features/kanban/KanbanColumn.tsx +++ b/src/components/features/kanban/KanbanColumn.tsx @@ -4,7 +4,7 @@ import { verticalListSortingStrategy, } from "@dnd-kit/sortable"; -import TaskCard from "@/components/features/task/card/TaskCard"; +import { TaskCard } from "@/features/task"; import { Task, TaskStatus } from "@/types"; import { EmptyState } from "@/components/shared/EmptyState"; import { getTaskStatusColor, isTaskOverdue } from "@/lib/utils/taskUtils"; diff --git a/src/components/features/notice/RichTextEditor.tsx b/src/components/features/notice/RichTextEditor.tsx index cc20cef..963cee5 100644 --- a/src/components/features/notice/RichTextEditor.tsx +++ b/src/components/features/notice/RichTextEditor.tsx @@ -10,9 +10,29 @@ import React, { } from "react"; import Button from "@/components/ui/Button"; +interface ToolbarButtonProps { + format: "bold" | "italic" | "list" | "heading"; + children: React.ReactNode; + shortcut?: string; + onApply: (format: "bold" | "italic" | "list" | "heading") => void; +} + +function ToolbarButton({ format, children, shortcut, onApply }: ToolbarButtonProps) { + return ( + + ); +} + interface RichTextEditorProps { value: string; - onChange: (e: any) => void; + onChange: (e: React.ChangeEvent | { target: { value: string } }) => void; placeholder?: string; rows?: number; className?: string; @@ -24,7 +44,7 @@ const RichTextEditor = forwardRef( const textareaRef = useReactRef(null); // 부모에게 textarea ref를 노출 - useImperativeHandle(ref, () => textareaRef.current, []); + useImperativeHandle(ref, () => textareaRef.current!, []); const [activeFormats, setActiveFormats] = useState({}); const [showPreview, setShowPreview] = useState(false); @@ -58,27 +78,8 @@ const RichTextEditor = forwardRef( setActiveFormats(active); }, []); - // 키보드 단축키 처리 - const handleKeyDown = useCallback((e: any) => { - // Ctrl/Cmd + B: 굵게 - if ((e.ctrlKey || e.metaKey) && e.key === "b") { - e.preventDefault(); - applyFormat("bold"); - } - // Ctrl/Cmd + I: 기울임 - else if ((e.ctrlKey || e.metaKey) && e.key === "i") { - e.preventDefault(); - applyFormat("italic"); - } - // Ctrl/Cmd + U: 목록 - else if ((e.ctrlKey || e.metaKey) && e.key === "u") { - e.preventDefault(); - applyFormat("list"); - } - }, []); - const applyFormat = useCallback( - (format: any) => { + (format: "bold" | "italic" | "list" | "heading") => { const textarea = textareaRef.current; if (!textarea) return; @@ -123,6 +124,28 @@ const RichTextEditor = forwardRef( [onChange, detectActiveFormats] ); + // 키보드 단축키 처리 + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + // Ctrl/Cmd + B: 굵게 + if ((e.ctrlKey || e.metaKey) && e.key === "b") { + e.preventDefault(); + applyFormat("bold"); + } + // Ctrl/Cmd + I: 기울임 + else if ((e.ctrlKey || e.metaKey) && e.key === "i") { + e.preventDefault(); + applyFormat("italic"); + } + // Ctrl/Cmd + U: 목록 + else if ((e.ctrlKey || e.metaKey) && e.key === "u") { + e.preventDefault(); + applyFormat("list"); + } + }, + [applyFormat] + ); + // 마크다운을 HTML로 변환 // 마크다운을 HTML로 변환하는 함수 const renderMarkdown = (text: string) => { @@ -152,30 +175,19 @@ const RichTextEditor = forwardRef( ); }; - const ToolbarButton = ({ format, children, shortcut }: any) => ( - - ); - return (
{/* 툴바 */}
- + B (굵게) - + I (기울임) - H1 (제목) - + H1 (제목) + • (목록)
@@ -197,7 +209,7 @@ const RichTextEditor = forwardRef( ref={textareaRef} // 내부 ref 사용 id={textareaId} value={value} - onChange={(e: any) => { + onChange={(e: React.ChangeEvent) => { onChange(e); detectActiveFormats(); }} diff --git a/src/components/features/project/ProjectBoardBody.tsx b/src/components/features/project/ProjectBoardBody.tsx index f8a998d..fd1dbcc 100644 --- a/src/components/features/project/ProjectBoardBody.tsx +++ b/src/components/features/project/ProjectBoardBody.tsx @@ -5,102 +5,73 @@ import { getProjectByIds, getProjectMemberByUser, } from "@/lib/api/projects"; -import { useState, useEffect, useMemo, useCallback } from "react"; +import { useState, useEffect, useMemo } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { queryKeys } from "@/lib/constants/queryKeys"; import { useSession } from "next-auth/react"; -import { showApiError } from "@/lib/utils/toast"; import { useProjectBoard } from "@/providers/ProjectBoardProvider"; import Container from "@/components/shared/Container"; import ProjectBoardEmpty from "./ProjectBoardEmpty"; import ProjectCard from "./ProjectCard"; import CommonPagination from "@/components/ui/CommonPagination"; -import { ProjectCardSkeleton } from "@/components/ui/ProjectCardSkeleton"; +import ProjectCardSkeleton from "@/components/ui/ProjectCardSkeleton"; +import type { Project } from "@/types/project"; export default function ProjectBoard() { const { data: session, status } = useSession(); const { filter } = useProjectBoard(); - const [isLoading, setIsLoading] = useState(true); - const [projectList, setProjectList] = useState([]); - const [projectMember, setProjectMember] = useState({}); + const userId = session?.user?.user_id; const [currentPage, setCurrentPage] = useState(1); - const [totalPage, setTotalPage] = useState(1); const ITEMS_PER_PAGE = 12; - // fetchAllData를 useCallback으로 감싸서 정의합니다. - // 이렇게 해야 이 함수가 의존성 배열에 들어가도 무한루프가 돌지 않습니다. - const fetchAllData = useCallback(async () => { - const userId = session?.user?.user_id; - if (status !== "authenticated" || !userId) return; + const { data: queryResult, isLoading } = useQuery({ + queryKey: queryKeys.projects.list(filter.view, currentPage, userId), + queryFn: async () => { + if (!userId) return null; - try { - setIsLoading(true); let projectResult = null; - // 프로젝트 목록 조회 + if (filter.view === "personal") { const { data: memberData } = await getProjectMemberByUser(userId); - // 참여 중인 프로젝트가 없는 경우 초기화 후 리턴 if (!memberData || memberData.length === 0) { - setIsLoading(false); - setTotalPage(0); - setProjectList([]); - setProjectMember({}); - return; + return { projectList: [] as Project[], projectMember: {} as Record, totalPage: 0 }; } - const currentIds = memberData - .map((memberData) => memberData.project_id) - .join(","); + const currentIds = memberData.map((m) => m.project_id).join(","); projectResult = await getProjectByIds(currentIds, currentPage); } else { projectResult = await getProject(currentPage); } const { data, totalCount } = projectResult; + const totalPage = totalCount ? Math.ceil(totalCount / ITEMS_PER_PAGE) : 1; - if (totalCount) { - const totalPages = Math.ceil(totalCount / ITEMS_PER_PAGE); - setTotalPage(totalPages); - } - - if (!data) { - setIsLoading(false); - return; - } - - // 프로젝트 목록 가공 - const formattedProjects = data.map((project) => ({ - ...project, - projectId: project.project_id, - projectName: project.project_name, - })); - setProjectList(formattedProjects); - - const memberMap = data.reduce((acc, project) => { - const countData = project.project_members; - const count = countData?.[0]?.count || 0; - acc[project.project_id] = count; - + const projectMember = (data || []).reduce>((acc, project) => { + acc[project.project_id] = project.project_members?.[0]?.count || 0; return acc; }, {}); - setProjectMember(memberMap); - } catch (err) { - console.error(err); - showApiError("데이터를 불러오는 중 오류가 발생했습니다."); - } - setIsLoading(false); - }, [filter.view, status, currentPage, session?.user?.user_id]); + return { + projectList: (data || []) as Project[], + projectMember, + totalPage, + }; + }, + enabled: status === "authenticated" && !!userId, + staleTime: 1000 * 60 * 3, + }); + + const projectList = queryResult?.projectList ?? []; + const projectMember = queryResult?.projectMember ?? {}; + const totalPage = queryResult?.totalPage ?? 1; useEffect(() => { setCurrentPage(1); }, [filter.view]); - useEffect(() => { - fetchAllData(); - }, [fetchAllData]); - const sortedProjectList = useMemo(() => { if (projectList.length === 0) return []; @@ -117,8 +88,8 @@ export default function ProjectBoard() { // 원본(projectList)을 건드리지 않고 복사하여 정렬 return [...projectList].sort((a, b) => { - const timeA = new Date((a as any)[targetKey]).getTime(); - const timeB = new Date((b as any)[targetKey]).getTime(); + const timeA = new Date(a[targetKey as keyof Project] as string).getTime(); + const timeB = new Date(b[targetKey as keyof Project] as string).getTime(); return isAsc ? timeA - timeB : timeB - timeA; }); @@ -182,7 +153,6 @@ export default function ProjectBoard() { ); diff --git a/src/components/features/project/ProjectBoardFilter.tsx b/src/components/features/project/ProjectBoardFilter.tsx index df5ad30..7f40461 100644 --- a/src/components/features/project/ProjectBoardFilter.tsx +++ b/src/components/features/project/ProjectBoardFilter.tsx @@ -1,3 +1,4 @@ +"use client"; import { useProjectBoard } from "@/providers/ProjectBoardProvider"; import { DateSelect } from "./DateSelect"; diff --git a/src/components/features/project/ProjectCard.tsx b/src/components/features/project/ProjectCard.tsx index 4a1e9ef..ac132a1 100644 --- a/src/components/features/project/ProjectCard.tsx +++ b/src/components/features/project/ProjectCard.tsx @@ -12,19 +12,21 @@ import { DeleteDialog } from "./DeleteDialog"; import { deleteProject, deleteProjectMember } from "@/lib/api/projects"; import { useRouter } from "next/navigation"; import { showToast } from "@/lib/utils/toast"; +import { useQueryClient } from "@tanstack/react-query"; +import { queryKeys } from "@/lib/constants/queryKeys"; +import type { Project } from "@/types/project"; interface ProjectCardProps { - project: any; - setProjectList: React.Dispatch>; - projectMember: any; + project: Project; + projectMember: Record | null; } export default function ProjectCard({ project, - setProjectList, projectMember, }: ProjectCardProps) { const router = useRouter(); + const queryClient = useQueryClient(); const handleSelectProject = (projectId: string) => { // 세션 스토리지에 선택한 프로젝트 ID 저장 @@ -46,10 +48,8 @@ export default function ProjectCard({ await deleteProject(id); await deleteProjectMember(id); - // UI에서 해당 프로젝트 제거 - setProjectList((prevList) => - prevList.filter((project) => project.projectId !== id) - ); + // 캐시 무효화 → 목록 자동 재조회 + queryClient.invalidateQueries({ queryKey: queryKeys.projects.all }); showToast("삭제되었습니다.", "deleted"); } @@ -63,7 +63,7 @@ export default function ProjectCard({ >
- {project.projectName} + {project.project_name}
@@ -84,18 +84,18 @@ export default function ProjectCard({
-
e.stopPropagation()}> +
) => e.stopPropagation()}>
-
e.stopPropagation()}> +
) => e.stopPropagation()}> handleDeleteProject(project.projectId)} + onClick={() => handleDeleteProject(project.project_id)} />
diff --git a/src/components/features/project/ProjectForm.tsx b/src/components/features/project/ProjectForm.tsx index 2d4ffe0..a149298 100644 --- a/src/components/features/project/ProjectForm.tsx +++ b/src/components/features/project/ProjectForm.tsx @@ -18,7 +18,9 @@ import { import { showToast } from "@/lib/utils/toast"; import { getUser, getUserById } from "@/lib/api/users"; import { useRouter } from "next/navigation"; -import { useEffect, useState } from "react"; +import { useState } from "react"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { queryKeys } from "@/lib/constants/queryKeys"; import { ComboBox, type Item } from "./ComboBox"; import ProjectDateCard from "./ProjectDateCard"; import { RoleSelect } from "./RoleSelect"; @@ -38,7 +40,9 @@ interface ProjectProps { export default function ProjectForm() { const router = useRouter(); - const [projectId, setProjectId] = useState(""); + const [projectId, setProjectId] = useState( + () => (typeof window !== "undefined" ? sessionStorage.getItem("current_Project_Id") ?? "" : "") + ); const [projectData, setProjectData] = useState({ projectName: "", type: "", @@ -51,91 +55,83 @@ export default function ProjectForm() { description: "", }); const [user, setUser] = useState(null); - const [userList, setUserList] = useState([]); - const [projectMember, setProjectMember] = useState([]); - - useEffect(() => { - const storedProjectId = sessionStorage.getItem("current_Project_Id"); - - if (storedProjectId) { - setProjectId(storedProjectId); - } - }, [router]); - - useEffect(() => { - const fetchData = async () => { - try { - // 유저 조회 - const userResult = await getUser(); - if (userResult.data) { - setUserList( - userResult.data.map(({ user_id, user_name, email }) => ({ - id: user_id, - label: `${user_name} (${email})`, - value: user_name, - email, - })) - ); - } - - if (!projectId) { - return; - } - - // 프로젝트 정보 및 멤버 조회 - const [projectResult, memberResult] = await Promise.all([ - getProjectById(projectId), - getProjectMember(projectId), - ]); - - - // 프로젝트 데이터 설정 - const project = projectResult.data?.[0]; - if (project) { - setProjectData({ - ...project, - projectName: project.project_name, - startedAt: project.started_at, - endedAt: project.ended_at, - createdAt: project.created_at, - updatedAt: project.updated_at, - techStack: project.tech_stack, - }); - } - - // 프로젝트 멤버 데이터 설정 - if (memberResult.data) { - const memberPromises = memberResult.data.map(async (member) => { - const { data } = await getUserById("eq", member.user_id); - const userInfo = data?.[0]; - - if (!userInfo) { - return null; - } + const queryClient = useQueryClient(); + + interface ProjectMemberItem { + projectId: string; + userId: string; + userName: string; + email: string; + role: string; + } + const [projectMember, setProjectMember] = useState([]); + + // 유저 목록 조회 + const { data: userList = [] } = useQuery({ + queryKey: queryKeys.users.all, + queryFn: async () => { + const result = await getUser(); + return (result.data || []).map(({ user_id, user_name, email }) => ({ + id: user_id, + label: `${user_name} (${email})`, + value: user_name, + email, + })) as Item[]; + }, + staleTime: 1000 * 60 * 10, + }); - return { - projectId: projectId, - userId: userInfo.user_id, - userName: userInfo.user_name, - email: userInfo.email, - role: member.role, - }; - }); + // 프로젝트 정보 + 멤버 조회 (수정 모드) + useQuery({ + queryKey: queryKeys.projectForm.detail(projectId), + queryFn: async () => { + if (!projectId) return null; + + const [projectResult, memberResult] = await Promise.all([ + getProjectById(projectId), + getProjectMember(projectId), + ]); + + const project = projectResult.data?.[0]; + if (project) { + setProjectData({ + ...project, + projectName: project.project_name, + startedAt: project.started_at, + endedAt: project.ended_at, + createdAt: project.created_at, + updatedAt: project.updated_at, + techStack: project.tech_stack, + }); + } - const validMembers = (await Promise.all(memberPromises)).filter( - Boolean - ); - setProjectMember(validMembers); - } - } catch (err) { - console.error(err); + if (memberResult.data) { + const memberPromises = memberResult.data.map(async (member) => { + const { data } = await getUserById("eq", member.user_id); + const userInfo = data?.[0]; + if (!userInfo) return null; + return { + projectId, + userId: userInfo.user_id, + userName: userInfo.user_name, + email: userInfo.email, + role: member.role, + }; + }); + const validMembers = (await Promise.all(memberPromises)).filter( + (m): m is ProjectMemberItem => m !== null + ); + setProjectMember(validMembers); } - }; - fetchData(); - }, [projectId]); + + return null; + }, + enabled: !!projectId, + staleTime: 1000 * 60 * 5, + }); // 일반 Input과 Textarea를 위한 handleChange - const handleChange = (event: any) => { + const handleChange = (event: React.ChangeEvent) => { const { name, value } = event.target; setProjectData((prevProjectData) => ({ ...prevProjectData, @@ -213,10 +209,8 @@ export default function ProjectForm() { setProjectMember(filterProjectMember); }; - const handleSubmit = async (event: any) => { - event.preventDefault(); - - try { + const { mutate: submitProject, isPending: isSubmitting } = useMutation({ + mutationFn: async () => { let targetId = projectId; if (!targetId) { @@ -229,13 +223,21 @@ export default function ProjectForm() { if (targetId) { await updateProjectMember(targetId, projectMember); } - + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.projects.all }); showToast("저장되었습니다.", "success"); router.push("/"); - } catch (error) { + }, + onError: (error) => { console.error(error); showToast("저장에 실패했습니다.", "error"); - } + }, + }); + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + submitProject(); }; return ( @@ -384,9 +386,10 @@ export default function ProjectForm() { variant="primary" size={16} className="hover:cursor-pointer mr-2 text-white" - onClick={handleSubmit} + onClick={() => submitProject()} + disabled={isSubmitting} > - {projectId ? "수정 완료" : "프로젝트 생성"} + {isSubmitting ? "저장 중..." : projectId ? "수정 완료" : "프로젝트 생성"}
diff --git a/src/components/features/project/ProjectListSkeleton.tsx b/src/components/features/project/ProjectListSkeleton.tsx index 590f644..7287568 100644 --- a/src/components/features/project/ProjectListSkeleton.tsx +++ b/src/components/features/project/ProjectListSkeleton.tsx @@ -7,7 +7,7 @@ } */ } -import { SectionHeader } from "../SectionHeader"; +import { SectionHeader } from "@/components/shared/SectionHeader"; import Container from "@/components/shared/Container"; export default function ProjectListSkeleton() { diff --git a/src/components/layout/Header.tsx b/src/components/layout/Header.tsx index 4648fcb..f8fe66b 100644 --- a/src/components/layout/Header.tsx +++ b/src/components/layout/Header.tsx @@ -4,24 +4,19 @@ import { useSession } from "next-auth/react"; import { showToast } from "@/lib/utils/toast"; import { Icon } from "@/components/shared/Icon"; import { useTheme } from "next-themes"; -import { useState, useEffect } from "react"; +import { useState } from "react"; import Link from "next/link"; import ProfileModal from "@/app/(auth)/login/components/ProfileModal"; import Button from "@/components/ui/Button"; import { isAdmin } from "@/lib/utils/auth"; export function Header() { - const { theme, setTheme } = useTheme(); + const { setTheme, resolvedTheme } = useTheme(); const [open, setOpen] = useState(false); - const [mounted, setMounted] = useState(false); const { data: session } = useSession(); const admin = isAdmin(session); - useEffect(() => { - setMounted(true); - }, []); - - let handleLoginModal = () => { + const handleLoginModal = () => { if (!session) { showToast("로그인이 필요합니다.", "alert"); return; @@ -83,15 +78,9 @@ export function Header() { btnType="icon" size={20} className="w-10 h-10" - onClick={() => setTheme(theme === "dark" ? "light" : "dark")} + onClick={() => setTheme(resolvedTheme === "dark" ? "light" : "dark")} > - {!mounted ? ( - - ) : theme === "dark" ? ( + {resolvedTheme === "dark" ? ( setOpen(false)} user={session?.user ?? null} /> diff --git a/src/components/ui/Badge.tsx b/src/components/ui/Badge.tsx index 43e9abd..3f7ef2c 100644 --- a/src/components/ui/Badge.tsx +++ b/src/components/ui/Badge.tsx @@ -1,9 +1,17 @@ import { bgColorOpacity } from "@/app/sample/color/page"; + +interface BadgeConfig { + title: string; + className: string; + category: "status" | "priority"; + dotColor?: string; +} + interface BadgeType { type: keyof typeof badgeConfigs; } -export const badgeConfigs = { +export const badgeConfigs: Record = { dueSoon: { title: "계획 진행중", className: `${bgColorOpacity.colorOpacity2[3]} text-white dark:text-white/80`, @@ -67,9 +75,8 @@ export default function Badge({ type }: BadgeType) { data-type={type} className={`py-1.5 px-2 rounded-sm text-xs font-medium ${badgeConfig.className}`} > - {(badgeConfig.category === "priority" || - (badgeConfig as any).dotColor) && ( - + {(badgeConfig.category === "priority" && badgeConfig.dotColor) && ( + )} {badgeConfig.title} diff --git a/src/components/ui/Commontable.tsx b/src/components/ui/CommonTable.tsx similarity index 100% rename from src/components/ui/Commontable.tsx rename to src/components/ui/CommonTable.tsx diff --git a/src/components/ui/Dropdown.tsx b/src/components/ui/Dropdown.tsx index ea985c3..d875988 100644 --- a/src/components/ui/Dropdown.tsx +++ b/src/components/ui/Dropdown.tsx @@ -76,6 +76,7 @@ export default function DropdownToggle({ }, ]; } + return []; }; const getOptions = options(); diff --git a/src/components/ui/Modal.tsx b/src/components/ui/Modal.tsx index 31f88a2..b402ac4 100644 --- a/src/components/ui/Modal.tsx +++ b/src/components/ui/Modal.tsx @@ -36,9 +36,9 @@ export default function Modal({ children, }: ModalProps) { // eslint-disable-next-line @typescript-eslint/no-explicit-any - const config = type + const config: any = type ? modalConfigs[type as keyof typeof modalConfigs] - : ({} as any); + : { title: undefined, description: undefined, warning: undefined }; // prop으로 전달받은 title, description이 있다면 사용하고, 없으면 config 값 사용 const finalTitle = title ?? config.title; const finalDescription = description ?? config.description; @@ -53,6 +53,7 @@ export default function Modal({ return () => clearTimeout(timer); } + return undefined; }, [type, onClose]); // body 스크롤 방지 diff --git a/src/components/ui/ProjectCardSkeleton.tsx b/src/components/ui/ProjectCardSkeleton.tsx index 085d115..9552d8f 100644 --- a/src/components/ui/ProjectCardSkeleton.tsx +++ b/src/components/ui/ProjectCardSkeleton.tsx @@ -1,6 +1,6 @@ import { Skeleton } from "@/components/ui/shadcn/Skeleton"; -export const ProjectCardSkeleton = () => { +export default function ProjectCardSkeleton() { return (
{/* 제목 */} diff --git a/src/components/ui/UserTableSkeleton.tsx b/src/components/ui/UserTableSkeleton.tsx index 40ad762..3b7d083 100644 --- a/src/components/ui/UserTableSkeleton.tsx +++ b/src/components/ui/UserTableSkeleton.tsx @@ -1,6 +1,6 @@ import { Skeleton } from "@/components/ui/shadcn/Skeleton"; -export const UserTableSkeleton = () => { +export default function UserTableSkeleton() { return (
{/* 이름 - text-center 영역 */} diff --git a/src/features/task/index.ts b/src/features/task/index.ts new file mode 100644 index 0000000..04ba1ed --- /dev/null +++ b/src/features/task/index.ts @@ -0,0 +1,5 @@ +export { default as TaskAdd } from "./ui/add/TaskAdd"; +export { default as TaskDetail } from "./ui/detail/TaskDetail"; +export { default as TaskCard } from "./ui/card/TaskCard"; + +export { dateTimeUtils, fetchProjectMembersForAssignment } from "./model"; diff --git a/src/features/task/model/dateTime.ts b/src/features/task/model/dateTime.ts new file mode 100644 index 0000000..9ec3ef7 --- /dev/null +++ b/src/features/task/model/dateTime.ts @@ -0,0 +1,19 @@ +export const dateTimeUtils = { + toISOString: (dateStr: string, timeStr?: string) => { + const time = timeStr || "00:00"; + return `${dateStr}T${time}:00.000Z`; + }, + + parseDateTime: (isoString?: string | null) => { + if (!isoString) return { date: "", time: "", hasTime: false }; + + const [datePart, timePart] = isoString.split("T"); + const [hours, minutes] = timePart.split(":"); + + return { + date: datePart, + time: `${hours}:${minutes}`, + hasTime: hours !== "00" || minutes !== "00", + }; + }, +} as const; diff --git a/src/features/task/model/index.ts b/src/features/task/model/index.ts new file mode 100644 index 0000000..0372081 --- /dev/null +++ b/src/features/task/model/index.ts @@ -0,0 +1,2 @@ +export { dateTimeUtils } from "./dateTime"; +export { fetchProjectMembersForAssignment } from "./projectMembers"; diff --git a/src/features/task/model/projectMembers.ts b/src/features/task/model/projectMembers.ts new file mode 100644 index 0000000..65af48e --- /dev/null +++ b/src/features/task/model/projectMembers.ts @@ -0,0 +1,22 @@ +import type { ProjectMemberWithUser } from "@/types/projectMember"; + +export async function fetchProjectMembersForAssignment( + projectId: string +): Promise { + const response = await fetch( + `/api/projectMembers/forAssignment?projectId=${projectId}` + ); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error( + errorData.error || + `HTTP ${response.status}: 프로젝트 멤버를 불러오는 데 실패했습니다.` + ); + } + + const result = await response.json(); + return result.data + ? (result.data as ProjectMemberWithUser[]) + : undefined; +} diff --git a/src/components/features/task/add/TaskAdd.tsx b/src/features/task/ui/add/TaskAdd.tsx similarity index 94% rename from src/components/features/task/add/TaskAdd.tsx rename to src/features/task/ui/add/TaskAdd.tsx index 590777c..bc3748e 100644 --- a/src/components/features/task/add/TaskAdd.tsx +++ b/src/features/task/ui/add/TaskAdd.tsx @@ -6,11 +6,12 @@ import { Task, TaskStatus, TaskPriority, Subtask } from "@/types"; import Button from "@/components/ui/Button"; // 공용 컴포넌트 -import { FormSection } from "@/components/features/task/shared/FormSection"; -import { StatusPrioritySection } from "@/components/features/task/shared/StatusPrioritySection"; -import { DateFields } from "@/components/features/task/shared/DateFields"; -import { SubtaskSection } from "@/components/features/task/shared/SubtaskSection"; -import { AssigneeField } from "@/components/features/task/fields/AssigneeField"; +import { FormSection } from "@/features/task/ui/shared/FormSection"; +import { StatusPrioritySection } from "@/features/task/ui/shared/StatusPrioritySection"; +import { DateFields } from "@/features/task/ui/shared/DateFields"; +import { SubtaskSection } from "@/features/task/ui/shared/SubtaskSection"; +import { AssigneeField } from "@/features/task/ui/fields/AssigneeField"; +import type { ProjectMemberWithUser } from "@/types/projectMember"; // ============================================ // Types & Constants @@ -45,17 +46,7 @@ type FormData = { subtasks: Subtask[]; }; -type ProjectMember = { - project_id: string; - user_id: string; - role: string; - users: { - id: string; - name: string; - email: string; - avatar_url: string; - }; -}; + const INITIAL_FORM_DATA: FormData = { title: "", @@ -106,7 +97,7 @@ export default function TaskAdd({ const [errors, setErrors] = useState>({}); const [isSubmitting, setIsSubmitting] = useState(false); const [isLoadingMembers, setIsLoadingMembers] = useState(false); - const [members, setMembers] = useState(null); + const [members, setMembers] = useState(undefined); // 프로젝트 종료 상태 체크 const isProjectEnded = (() => { @@ -126,7 +117,7 @@ export default function TaskAdd({ throw new Error("프로젝트 멤버를 불러오는 데 실패했습니다."); } const result = await response.json(); - setMembers(result.data || []); + setMembers((result.data as ProjectMemberWithUser[]) || undefined); } catch (error) { console.error(error); setErrors((prev) => ({ diff --git a/src/components/features/task/card/TaskCard.tsx b/src/features/task/ui/card/TaskCard.tsx similarity index 96% rename from src/components/features/task/card/TaskCard.tsx rename to src/features/task/ui/card/TaskCard.tsx index a742c25..2217679 100644 --- a/src/components/features/task/card/TaskCard.tsx +++ b/src/features/task/ui/card/TaskCard.tsx @@ -3,10 +3,10 @@ import { useSortable } from "@dnd-kit/sortable"; import { Task } from "@/types"; import { CSS } from "@dnd-kit/utilities"; import { Check } from "lucide-react"; -import PriorityBadge from "@/components/features/task/fields/PriorityBadge"; -import AssigneeInfo from "@/components/features/task/fields/AssigneeInfo"; -import SubtaskList from "@/components/features/task/fields/SubtaskList"; -import DateInfo from "@/components/features/task/fields/DateInfo"; +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; diff --git a/src/components/features/task/detail/TaskDetail.tsx b/src/features/task/ui/detail/TaskDetail.tsx similarity index 89% rename from src/components/features/task/detail/TaskDetail.tsx rename to src/features/task/ui/detail/TaskDetail.tsx index 69fbe56..1886a21 100644 --- a/src/components/features/task/detail/TaskDetail.tsx +++ b/src/features/task/ui/detail/TaskDetail.tsx @@ -14,46 +14,22 @@ import { useModal } from "@/hooks/useModal"; // 모달 상태 관리 훅 import Modal from "@/components/ui/Modal"; // 모달 컴포넌트 // Task 관련 공용 컴포넌트들 - 재사용성을 위해 분리 -import { FormSection } from "@/components/features/task/shared/FormSection"; // 폼 섹션 래퍼 -import { StatusPrioritySection } from "@/components/features/task/shared/StatusPrioritySection"; // 상태/우선순위 -import { DateFields } from "@/components/features/task/shared/DateFields"; // 날짜 입력 필드들 -import { SubtaskSection } from "@/components/features/task/shared/SubtaskSection"; // 서브태스크 관리 -import { AssigneeField } from "@/components/features/task/fields/AssigneeField"; // 담당자 선택 +import { FormSection } from "@/features/task/ui/shared/FormSection"; // 폼 섹션 래퍼 +import { StatusPrioritySection } from "@/features/task/ui/shared/StatusPrioritySection"; // 상태/우선순위 +import { DateFields } from "@/features/task/ui/shared/DateFields"; // 날짜 입력 필드들 +import { SubtaskSection } from "@/features/task/ui/shared/SubtaskSection"; // 서브태스크 관리 +import { AssigneeField } from "@/features/task/ui/fields/AssigneeField"; // 담당자 선택 // 타입 정의 import { Task } from "@/types/kanban"; +import type { ProjectMemberWithUser } from "@/types/projectMember"; -// ============================================ -// 🛠️ 유틸리티 함수들 -// ============================================ -/** - * 📅 날짜/시간 변환 유틸리티 - * - * ISO 문자열과 UI 표시용 날짜/시간 간 변환을 담당 - * DB 저장 형식과 사용자 친화적 형식 간의 브릿지 역할 - */ -const dateTimeUtils = { - // 🔄 저장용: 날짜+시간을 ISO 문자열로 변환 (DB 저장용) - toISOString: (dateStr: string, timeStr?: string) => { - const time = timeStr || "00:00"; // 시간이 없으면 자정으로 - return `${dateStr}T${time}:00.000Z`; // ISO 8601 형식 - }, - - // 🎨 표시용: ISO 문자열을 날짜/시간으로 분리 (UI 표시용) - parseDateTime: (isoString?: string | null) => { - if (!isoString) return { date: "", time: "", hasTime: false }; - - const [datePart, timePart] = isoString.split("T"); // ISO 문자열 파싱 - const [hours, minutes] = timePart.split(":"); - - return { - date: datePart, // YYYY-MM-DD 형식 - time: `${hours}:${minutes}`, // HH:MM 형식 - hasTime: hours !== "00" || minutes !== "00", // 실제 시간 정보 있는지 확인 - }; - }, -}; +// model +import { + dateTimeUtils, + fetchProjectMembersForAssignment, +} from "@/features/task/model"; // ============================================ // 📋 타입 정의 @@ -134,7 +110,7 @@ export default function TaskDetail({ const [editingField, setEditingField] = useState(null); // 현재 편집 중인 필드 const [isLoadingMembers, setIsLoadingMembers] = useState(false); // 멤버 로딩 상태 const [isLoadingAssignee, setIsLoadingAssignee] = useState(false); // assignee 보강 로딩 상태 - const [members, setMembers] = useState(null); // 프로젝트 멤버 목록 + const [members, setMembers] = useState(undefined); // 프로젝트 멤버 목록 const { openModal, modalProps } = useModal(); // 삭제 확인 모달 관리 // 프로젝트 종료 상태 체크 @@ -161,34 +137,13 @@ export default function TaskDetail({ setIsLoadingMembers(true); // 로딩 상태 시작 try { - // 🌐 API 호출: 프로젝트 멤버 목록 요청 - const response = await fetch( - `/api/projectMembers/forAssignment?projectId=${task.project_id}` - ); - - // 🚑 HTTP 에러 처리 - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error( - errorData.error || - `HTTP ${response.status}: 프로젝트 멤버를 불러오는 데 실패했습니다.` - ); - } - - const result = await response.json(); - - // 📈 응답 데이터 처리 - if (result.data) { - setMembers(result.data); // 성공: 멤버 목록 설정 - } else { - console.warn("프로젝트 멤버 데이터가 없습니다:", result); - setMembers([]); // 데이터 없음: 빈 배열 - } + const data = await fetchProjectMembersForAssignment(task.project_id); + setMembers(data); } catch (error) { // 🚑 예외 처리: 네트워크 오류, API 에러 등 console.error("프로젝트 멤버 조회 에러:", error); - // 에러가 발생해도 UI가 깨지지 않도록 빈 배열로 설정 - setMembers([]); + // 에러가 발생해도 UI가 깨지지 않도록 undefined로 설정 + setMembers(undefined); } finally { setIsLoadingMembers(false); // 로딩 상태 종료 } @@ -427,7 +382,6 @@ export default function TaskDetail({ !isProjectEnded && handleChange("status", v)} onPriorityChange={(v) => !isProjectEnded && handleChange("priority", v) @@ -615,6 +569,27 @@ function Header({ ); } +// ============================================ +// 내부 컴포넌트 Props 타입 +// ============================================ +interface EditableFieldProps { + value: string | null | undefined; + isEditing: boolean; + isProjectEnded?: boolean; + onEdit: () => void; + onChange: (value: string) => void; + onBlur: () => void; + onCancel: () => void; +} + +interface ActionButtonsProps { + hasChanges: boolean; + isProjectEnded: boolean; + onCancel: () => void; + onSave: () => void; + onDelete: () => void; +} + /** * 📝 TitleField 컴포넌트 - 인라인 편집 가능한 제목 필드 * @@ -632,12 +607,12 @@ function TitleField({ onChange, // 🔄 값 변경 핸들러 onBlur, // 👁️ 포커스 이탈 핸들러 onCancel, // ❌ 취소 핸들러 -}: any) { +}: EditableFieldProps) { if (isEditing) { return ( onChange(e.target.value)} onBlur={onBlur} onKeyDown={(e) => { @@ -672,7 +647,7 @@ function DescriptionField({ onChange, onBlur, onCancel, -}: any) { +}: EditableFieldProps) { return isEditing ? (