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/.vscode/mcp.json b/.vscode/mcp.json new file mode 100644 index 0000000..2350667 --- /dev/null +++ b/.vscode/mcp.json @@ -0,0 +1,8 @@ +{ + "servers": { + "supabase": { + "type": "http", + "url": "https://mcp.supabase.com/mcp?project_ref=jjlqgtrtmwnegbhokmaq&read_only=true" + } + } +} diff --git a/README.md b/README.md index 36e1231..395db96 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,8 @@ **Taskry**는 복잡한 절차 없이 누구나 쉽게 업무 흐름을 관리할 수 있는 웹 기반 협업 도구입니다. 직관적인 UI와 실시간 동기화를 통해 팀의 생산성을 극대화합니다. +업데이트일: 2026-05-10 + ## 📸 Screen Shots | 메인 대시보드 | 칸반보드 | 캘린더 | @@ -79,9 +81,24 @@ DIRECT_URL=your+direct_url # Supabase API NEXT_PUBLIC_SUPABASE_URL=your_supabase_url NEXT_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key +SUPABASE_SERVICE_ROLE_KEY=your_supabase_service_role_key ``` -### 3. 실행 명령어 +### 3. 인증 운영 방식 + +- 현재 인증은 NextAuth와 Supabase Auth를 병행 운영합니다. +- 로그인 페이지에서 NextAuth Google 또는 Supabase Google 로그인으로 진입할 수 있습니다. +- 보호 라우트는 NextAuth 토큰과 Supabase 세션을 함께 확인합니다. +- 서버 API는 통합 인증 유틸을 통해 사용자 컨텍스트를 추출합니다. +- 관리자 API는 role === "admin" 기준으로만 허용합니다. + +### 4. 로그인 오류 점검 순서 + +- Google OAuth Client ID/Secret과 승인된 Redirect URI를 먼저 확인합니다. +- Supabase OAuth Redirect URL과 앱의 redirectTo 설정 일치 여부를 확인합니다. +- 브라우저 쿠키/세션 상태와 서버 환경변수 누락 여부를 확인합니다. + +### 5. 실행 명령어 ``` npm run dev 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..7f575d5 100644 --- a/src/app/(auth)/login/page.tsx +++ b/src/app/(auth)/login/page.tsx @@ -2,11 +2,12 @@ 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 { redirect, useSearchParams } from "next/navigation"; +import { useEffect, Suspense } from "react"; import Container from "@/components/shared/Container"; +import { signInWithSupabaseGoogle } from "@/lib/supabase/auth"; -export default function LoginPage() { +function LoginContent() { const searchParams = useSearchParams(); useEffect(() => { @@ -16,8 +17,6 @@ export default function LoginPage() { if (inviteId) { localStorage.setItem("invite_id", inviteId); } - - console.log(inviteId,"inviteId") }, [searchParams]); return ( @@ -52,7 +51,30 @@ export default function LoginPage() { > Google로 시작하기 + + +
+ +
+ +
); } + +export default function LoginPage() { + return ( + + + + ); +} diff --git a/src/app/(main)/notice/[id]/page.tsx b/src/app/(main)/notice/[id]/page.tsx index 83917ac..765945a 100644 --- a/src/app/(main)/notice/[id]/page.tsx +++ b/src/app/(main)/notice/[id]/page.tsx @@ -13,7 +13,6 @@ import { NoticeNavigation } from "@/components/features/notice/NoticeNavigation" import { NoticeActionButtons } from "@/components/features/notice/NoticeActionButtons"; import { useNoticeDelete } from "@/hooks/notice/useNoticeDelete"; import { useNoticeForm } from "@/hooks/notice/useNoticeForm"; -import { isAdmin } from "@/lib/utils/auth"; import { NOTICE_MESSAGES } from "@/lib/constants/notices"; import Link from "next/link"; import Container from "@/components/shared/Container"; @@ -27,7 +26,7 @@ export default function NoticeDetail() { // ------------------ ID 추출 및 타입 안정성 확보 const { data: session } = useSession(); const noticeId = (Array.isArray(params.id) ? params.id[0] : params.id) ?? ""; - const admin = isAdmin(session); + const admin = session?.user?.role === "admin"; // ------------------ 공지사항 데이터 로드 const { notice, nextNotice, prevNotice, isLoading, error, reload } = 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)/notice/page.tsx b/src/app/(main)/notice/page.tsx index da13436..c9d4ce2 100644 --- a/src/app/(main)/notice/page.tsx +++ b/src/app/(main)/notice/page.tsx @@ -25,7 +25,6 @@ import { SectionHeader } from "@/components/shared/SectionHeader"; import { NoticeWithNumber } from "@/types/notice"; import { useSession } from "next-auth/react"; import { NOTICE_MESSAGES } from "@/lib/constants/notices"; -import { isAdmin } from "@/lib/utils/auth"; import { useNoticeDelete } from "@/hooks/notice/useNoticeDelete"; import Link from "next/link"; import EmptyNotice from "@/components/features/notice/EmptyNotice"; @@ -36,7 +35,7 @@ import CommonPagination from "@/components/ui/CommonPagination"; export default function NoticePage() { const { data: session } = useSession(); - const admin = isAdmin(session); + const admin = session?.user?.role === "admin"; const [notices, setNotices] = useState([]); const [currentPage, setCurrentPage] = useState(1); @@ -96,7 +95,7 @@ export default function NoticePage() { <>

총 {totalItems}개

- {admin && ( + {session?.user?.role === "admin" && ( + ); +} + 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/layout/Header.tsx b/src/components/layout/Header.tsx index 4648fcb..18d99f9 100644 --- a/src/components/layout/Header.tsx +++ b/src/components/layout/Header.tsx @@ -4,24 +4,17 @@ 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; @@ -68,7 +61,7 @@ export function Header() { > - {admin && ( + {session?.user?.role === "admin" && (
-
e.stopPropagation()}> +
) => e.stopPropagation()}> handleDeleteProject(project.projectId)} + onClick={() => handleDeleteProject(project.project_id)} />
diff --git a/src/components/features/project/ProjectDateCard.tsx b/src/features/project/ui/ProjectDateCard.tsx similarity index 100% rename from src/components/features/project/ProjectDateCard.tsx rename to src/features/project/ui/ProjectDateCard.tsx diff --git a/src/components/features/project/ProjectForm.tsx b/src/features/project/ui/ProjectForm.tsx similarity index 73% rename from src/components/features/project/ProjectForm.tsx rename to src/features/project/ui/ProjectForm.tsx index 2d4ffe0..c1c3019 100644 --- a/src/components/features/project/ProjectForm.tsx +++ b/src/features/project/ui/ProjectForm.tsx @@ -2,9 +2,9 @@ import Button from "@/components/ui/Button"; import { Icon } from "@/components/shared/Icon"; -import { Calendar22 } from "@/components/features/project/Calendar"; -import { StatusSelect } from "@/components/features/project/StatusSelect"; -import { TypeSelect } from "@/components/features/project/TypeSelect"; +import { Calendar22 } from "@/features/project/ui/Calendar"; +import { StatusSelect } from "@/features/project/ui/StatusSelect"; +import { TypeSelect } from "@/features/project/ui/TypeSelect"; import { Input } from "@/components/ui/shadcn/Input"; import { Label } from "@/components/ui/shadcn/Label"; import { Textarea } from "@/components/ui/shadcn/Textarea"; @@ -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/ProjectInfoPanel.tsx b/src/features/project/ui/ProjectInfoPanel.tsx similarity index 100% rename from src/components/features/project/ProjectInfoPanel.tsx rename to src/features/project/ui/ProjectInfoPanel.tsx diff --git a/src/components/features/project/ProjectList.tsx b/src/features/project/ui/ProjectList.tsx similarity index 100% rename from src/components/features/project/ProjectList.tsx rename to src/features/project/ui/ProjectList.tsx diff --git a/src/components/features/project/ProjectListSkeleton.tsx b/src/features/project/ui/ProjectListSkeleton.tsx similarity index 97% rename from src/components/features/project/ProjectListSkeleton.tsx rename to src/features/project/ui/ProjectListSkeleton.tsx index 590f644..7287568 100644 --- a/src/components/features/project/ProjectListSkeleton.tsx +++ b/src/features/project/ui/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/features/project/ProjectPagination.tsx b/src/features/project/ui/ProjectPagination.tsx similarity index 100% rename from src/components/features/project/ProjectPagination.tsx rename to src/features/project/ui/ProjectPagination.tsx diff --git a/src/components/features/project/RoleSelect.tsx b/src/features/project/ui/RoleSelect.tsx similarity index 100% rename from src/components/features/project/RoleSelect.tsx rename to src/features/project/ui/RoleSelect.tsx diff --git a/src/components/features/project/SampleSkeleton.tsx b/src/features/project/ui/SampleSkeleton.tsx similarity index 100% rename from src/components/features/project/SampleSkeleton.tsx rename to src/features/project/ui/SampleSkeleton.tsx diff --git a/src/components/features/project/SortRadioGroup.tsx b/src/features/project/ui/SortRadioGroup.tsx similarity index 100% rename from src/components/features/project/SortRadioGroup.tsx rename to src/features/project/ui/SortRadioGroup.tsx diff --git a/src/components/features/project/SortSelect.tsx b/src/features/project/ui/SortSelect.tsx similarity index 100% rename from src/components/features/project/SortSelect.tsx rename to src/features/project/ui/SortSelect.tsx diff --git a/src/components/features/project/StatusSelect.tsx b/src/features/project/ui/StatusSelect.tsx similarity index 100% rename from src/components/features/project/StatusSelect.tsx rename to src/features/project/ui/StatusSelect.tsx diff --git a/src/components/features/project/TypeSelect.tsx b/src/features/project/ui/TypeSelect.tsx similarity index 100% rename from src/components/features/project/TypeSelect.tsx rename to src/features/project/ui/TypeSelect.tsx diff --git a/src/components/features/project/ViewSelect.tsx b/src/features/project/ui/ViewSelect.tsx similarity index 100% rename from src/components/features/project/ViewSelect.tsx rename to src/features/project/ui/ViewSelect.tsx 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 ? (