diff --git a/package-lock.json b/package-lock.json index 54d2309..dc7ceeb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,9 +12,11 @@ "@tanstack/react-query": "^5.80.2", "axios": "^1.9.0", "clsx": "^2.1.1", + "date-fns": "^4.1.0", "next": "14.2.25", "next-themes": "^0.4.6", "react": "18.2.0", + "react-datepicker": "^8.4.0", "react-dom": "18.2.0", "react-hook-form": "^7.57.0", "sonner": "^2.0.5", @@ -228,6 +230,59 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@floating-ui/core": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.1.tgz", + "integrity": "sha512-azI0DrjMMfIug/ExbBaeDVJXcY0a7EPvPjb2xAJPa4HeimBX+Z18HK8QQR3jb6356SnDDdxx+hinMLcJEDdOjw==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.9" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.1.tgz", + "integrity": "sha512-cwsmW/zyw5ltYTUeeYJ60CnQuPqmGwuGVhG9w0PRaRKkAyi38BT5CKrpIbb+jtahSwUl04cWzSx9ZOIxeS6RsQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.1", + "@floating-ui/utils": "^0.2.9" + } + }, + "node_modules/@floating-ui/react": { + "version": "0.27.12", + "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.27.12.tgz", + "integrity": "sha512-kKlWNrpIQxF1B/a2MZvE0/uyKby4960yjO91W7nVyNKmmfNi62xU9HCjL1M1eWzx/LFj/VPSwJVbwQk9Pq/68A==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.1.3", + "@floating-ui/utils": "^0.2.9", + "tabbable": "^6.0.0" + }, + "peerDependencies": { + "react": ">=17.0.0", + "react-dom": ">=17.0.0" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.3.tgz", + "integrity": "sha512-huMBfiU9UnQ2oBwIhgzyIiSpVgvlDstU8CX0AF+wS+KzmYMs0J2a3GwuFHV1Lz+jlrQGeC1fF+Nv0QoumyV0bA==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.9.tgz", + "integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==", + "license": "MIT" + }, "node_modules/@humanwhocodes/config-array": { "version": "0.13.0", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", @@ -1999,6 +2054,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, "node_modules/debug": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", @@ -5124,6 +5189,21 @@ "node": ">=0.10.0" } }, + "node_modules/react-datepicker": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/react-datepicker/-/react-datepicker-8.4.0.tgz", + "integrity": "sha512-6nPDnj8vektWCIOy9ArS3avus9Ndsyz5XgFCJ7nBxXASSpBdSL6lG9jzNNmViPOAOPh6T5oJyGaXuMirBLECag==", + "license": "MIT", + "dependencies": { + "@floating-ui/react": "^0.27.3", + "clsx": "^2.1.1", + "date-fns": "^4.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17 || ^18 || ^19 || ^19.0.0-rc", + "react-dom": "^16.9.0 || ^17 || ^18 || ^19 || ^19.0.0-rc" + } + }, "node_modules/react-dom": { "version": "18.2.0", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz", @@ -5973,6 +6053,12 @@ "url": "https://opencollective.com/synckit" } }, + "node_modules/tabbable": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz", + "integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==", + "license": "MIT" + }, "node_modules/tailwind-merge": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.3.0.tgz", diff --git a/package.json b/package.json index 2eefafa..7b4bca4 100644 --- a/package.json +++ b/package.json @@ -13,9 +13,11 @@ "@tanstack/react-query": "^5.80.2", "axios": "^1.9.0", "clsx": "^2.1.1", + "date-fns": "^4.1.0", "next": "14.2.25", "next-themes": "^0.4.6", "react": "18.2.0", + "react-datepicker": "^8.4.0", "react-dom": "18.2.0", "react-hook-form": "^7.57.0", "sonner": "^2.0.5", diff --git a/public/images/plus.svg b/public/images/plus.svg index eab56df..d72c5e8 100644 --- a/public/images/plus.svg +++ b/public/images/plus.svg @@ -1,3 +1,3 @@ - - + + diff --git a/src/app/api/useCards.ts b/src/app/api/useCards.ts deleted file mode 100644 index d6000ea..0000000 --- a/src/app/api/useCards.ts +++ /dev/null @@ -1,46 +0,0 @@ -//size일단 10으로 하고, 나중에 커서아이디 받아서 무한 스크롤 구현해야 함. -import { useQuery } from '@tanstack/react-query' - -import axiosClient from './axiosClient' - -export interface Assignee { - id: number - nickname: string - profileImageUrl: string | null -} -export interface Card { - id: number - title: string - description: string - tags: string[] - dueDate: string - assignee: Assignee - imageUrl: string - teamId: string - dashboardId: number - columnId: number - createdAt: string - updatedAt: string -} -export interface CardResponse { - cards: Card[] - totalCount: number - cursorId: number -} - -export async function fetchCards( - columnId: number, - size: number = 10, -): Promise { - const res = await axiosClient.get( - `/cards?size=${size}&columnId=${columnId}`, - ) - return res.data -} - -export default function useCards(columnId: number) { - return useQuery({ - queryKey: ['columnId', columnId], - queryFn: () => fetchCards(columnId), - }) -} diff --git a/src/app/api/useColumns.ts b/src/app/api/useColumns.ts deleted file mode 100644 index 7b68d18..0000000 --- a/src/app/api/useColumns.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { useQuery } from '@tanstack/react-query' - -import axiosClient from './axiosClient' - -//타입 -export interface Column { - id: number - title: string - teamId: string - dashboardId: number - createdAt: string - updatedAt: string -} -export interface ColumnsResponse { - data: Column[] -} - -//fetch 함수 (API 호출 전용) -export async function fetchColumns(dashboardId: number): Promise { - const res = await axiosClient.get( - `/columns?dashboardId=${dashboardId}`, - ) - return res.data.data -} - -//useQuery -export default function useColumns(dashboardId: number) { - return useQuery({ - queryKey: ['columns', dashboardId], - queryFn: () => fetchColumns(dashboardId), - }) -} diff --git a/src/app/dashboard/[id]/api/updateCardColumn.ts b/src/app/dashboard/[id]/api/updateCardColumn.ts deleted file mode 100644 index b2cc903..0000000 --- a/src/app/dashboard/[id]/api/updateCardColumn.ts +++ /dev/null @@ -1,12 +0,0 @@ -import axiosClient from '@/app/api/axiosClient' - -// 카드 이동 - 해당 카드의 컬럼ID를 변경하는 방식(PUT) -export async function updateCardColumn( - cardId: number, - columnId: number, -): Promise<{ success: boolean }> { - const res = await axiosClient.put<{ success: boolean }>(`/cards/${cardId}`, { - columnId: columnId, - }) - return res.data -} diff --git a/src/app/dashboard/[id]/layout.tsx b/src/app/dashboard/[id]/layout.tsx new file mode 100644 index 0000000..f7f1488 --- /dev/null +++ b/src/app/dashboard/[id]/layout.tsx @@ -0,0 +1,17 @@ +import Header from '@components/common/header/Header' + +import Sidebar from '@/app/shared/components/common/sidebar/Sidebar' + +export default function AboutLayout({ + children, +}: { + children: React.ReactNode +}) { + return ( +
+ {/* */} +
+
{children}
{/* 여기에 page.tsx 내용이 들어옴 */} +
+ ) +} diff --git a/src/app/dashboard/[id]/page.tsx b/src/app/dashboard/[id]/page.tsx index 1414dc4..4aa117a 100644 --- a/src/app/dashboard/[id]/page.tsx +++ b/src/app/dashboard/[id]/page.tsx @@ -1,18 +1,21 @@ 'use client' import Image from 'next/image' +import { useParams } from 'next/navigation' import { useRef } from 'react' -import useColumns from '@/app/api/useColumns' - -import { useCardMutation } from './api/useCardMutation' -import Column from './Column/Column' -import { useDragStore } from './store/useDragStore' -import type { Card } from './type/Card' +import { useCardMutation } from '@/app/features/dashboard_Id/api/useCardMutation' +import useColumns from '@/app/features/dashboard_Id/api/useColumns' +import Column from '@/app/features/dashboard_Id/Column/Column' +import { useDragStore } from '@/app/features/dashboard_Id/store/useDragStore' +import { Card } from '@/app/features/dashboard_Id/type/Card.type' export default function DashboardID() { - const dashboard = 15120 - const { data: columns, isLoading, error } = useColumns(dashboard) + const params = useParams() + const dashboardId = Number(params.id) + const { data: columns, isLoading, error } = useColumns(dashboardId) + // const { data: columns, isLoading, error } = useColumns(id) + const { draggingCard, setDraggingCard } = useDragStore() const cardMutation = useCardMutation() const touchPos = useRef({ x: 0, y: 0 }) @@ -124,10 +127,9 @@ export default function DashboardID() { if (error) return

error...{error.message}

return ( <> -
사이드바
{tags.map((tag) => { - const colorIndex = getColor(tag, bgColors) + const colorIndex = getColor(tag, bgColors.length) return ( > +} +export default function AssigneeList({ + setSelectedAssignee, +}: AssigneeListProps) { + return ( +
+ {mockData.map((Assignee, index) => ( +
{ + setSelectedAssignee(Assignee) + console.log(Assignee) + }} + > + {Assignee} +
+ ))} +
+ ) +} diff --git a/src/app/dashboard/[id]/Card/Card.tsx b/src/app/features/dashboard_Id/Card/Card.tsx similarity index 58% rename from src/app/dashboard/[id]/Card/Card.tsx rename to src/app/features/dashboard_Id/Card/Card.tsx index 6076e8c..9741586 100644 --- a/src/app/dashboard/[id]/Card/Card.tsx +++ b/src/app/features/dashboard_Id/Card/Card.tsx @@ -1,9 +1,9 @@ import Image from 'next/image' -import type { Card as CardType } from '@/app/api/useCards' import { Avatar } from '@/app/shared/components/common/Avatar' import { useDragStore } from '../store/useDragStore' +import type { Card as CardType } from '../type/Card.type' import Tags from './Tags' export default function Card({ @@ -17,7 +17,7 @@ export default function Card({ const { setDraggingCard } = useDragStore() return (
setDraggingCard({ cardData: card })} @@ -35,29 +35,42 @@ export default function Card({ draggable="false" /> )} + + {/* 할 일 제목 */}

{title}

+ + {/* 태그 */} + + {/* 마감일 & 담당자 */}
-
- 마감일 -
- {dueDate} + {/* :마감일 */} + {dueDate && ( +
+ 마감일 +
+ {dueDate} +
+
+ )} + + {/* :담당자 */} + {assignee && ( +
+
-
-
- -
+ )}
) diff --git a/src/app/features/dashboard_Id/Card/Tags.tsx b/src/app/features/dashboard_Id/Card/Tags.tsx new file mode 100644 index 0000000..6832b12 --- /dev/null +++ b/src/app/features/dashboard_Id/Card/Tags.tsx @@ -0,0 +1,29 @@ +import { getColor } from '@/app/shared/lib/getColor' + +export default function Tags({ tags }: { tags: string[] }) { + //태그 컬러 - 랜덤 배정 + //카드 생성 시 - 동일 태그 입력 불가하도록 + const bgColors = ['#F9EEE3', '#E7F7DB', '#F7DBF0', '#DBE6F7'] + const textColors = ['#D58D49', '#86D549', '#D549B6', '#4981D5'] + + return ( +
+ {tags.map((tag) => { + const colorIndex = getColor(tag, bgColors.length) + + return ( + + {tag} + + ) + })} +
+ ) +} diff --git a/src/app/features/dashboard_Id/Card/cardFormModals/AssigneeList.tsx b/src/app/features/dashboard_Id/Card/cardFormModals/AssigneeList.tsx new file mode 100644 index 0000000..1e5a4a5 --- /dev/null +++ b/src/app/features/dashboard_Id/Card/cardFormModals/AssigneeList.tsx @@ -0,0 +1,50 @@ +import { ControllerRenderProps } from 'react-hook-form' + +import { cn } from '@/app/shared/lib/cn' + +import getDashboardMembers from '../../lib/getDashboardMembers' +import { CardFormData } from '../../type/CardFormData.type' +import { Member } from '../../type/Member.type' + +export interface Assignee { + userId: number + nickname: string +} +interface AssigneeListProps { + members: Member[] | undefined + setAssignee: (assignee: Assignee) => void + controlField: ControllerRenderProps +} + +// ✅ AssigneeList 컴포넌트: 담당자 후보 목록을 보여주는 드롭다운 리스트 +// 1. 담당자 항목을 클릭하면: +// - setAssignee(assignee) 실행 → 선택된 담당자 객체를 부모 컴포넌트 하에 관리 (ex. UI에서 닉네임 표시용) +// - controlField.onChange(assignee.userId) 실행 → react-hook-form에 userId 값을 전달 (form 제출에는 Id 데이터만 전달함) + +export default function AssigneeList({ + members, + setAssignee, + controlField, // react-hook-form의 컨트롤 필드 객체 (assigneeUserId 필드와 연결됨) +}: AssigneeListProps) { + const assignees = getDashboardMembers(members) + + return ( +
+ {assignees.map((assignee: Assignee, index: number) => ( +
{ + setAssignee(assignee) // 담당자 업데이트 + controlField.onChange(assignee.userId) // 리액트 훅에는 .userId 값 연결 + }} + > + {assignee.nickname} +
+ ))} +
+ ) +} diff --git a/src/app/features/dashboard_Id/Card/cardFormModals/CreateCardForm.tsx b/src/app/features/dashboard_Id/Card/cardFormModals/CreateCardForm.tsx new file mode 100644 index 0000000..f2815af --- /dev/null +++ b/src/app/features/dashboard_Id/Card/cardFormModals/CreateCardForm.tsx @@ -0,0 +1,281 @@ +import 'react-datepicker/dist/react-datepicker.css' + +import { format } from 'date-fns' +import Image from 'next/image' +import { useParams } from 'next/navigation' +import { useEffect, useState } from 'react' +import DatePicker from 'react-datepicker' +import { Controller, useForm } from 'react-hook-form' + +import useMembers from '../../api/useMembers' +import { usePostCard } from '../../api/usePostCard' +import { useUploadCardImage } from '../../api/useUploadCardImage' +import type { CardFormData } from '../../type/CardFormData.type' +import Tags from '../Tags' +import AssigneeList, { Assignee } from './AssigneeList' +import DateInput from './input/DateInput' +import Input from './input/Input' + +export default function CreateCardForm({ + onClose, + columnId, +}: { + onClose: () => void + columnId: number +}) { + const [preview, setPreview] = useState(null) // 이미지 URl 임시 저장 + const [tags, setTags] = useState([]) // 태그 목록 임시 저장 + const [tagInput, setTagInput] = useState('') // 작성중인 태그 + const { mutate: uploadImage, isPending: isUploading } = useUploadCardImage() + + // 대시보드 멤버(담당자 선택) + const params = useParams() + const dashboardId = Number(params.id) + const { data } = useMembers(dashboardId) + const [isOpen, setIsOpen] = useState(false) // 담당자 드롭다운 + const [selectedAssignee, setSelectedAssignee] = useState() // 선택한 담당자 + + const { + register, + control, + handleSubmit, + setValue, + formState: { errors, isValid, isSubmitting }, + } = useForm({ + defaultValues: { + imageUrl: '', // 이미지 첨부 안하면 기본값은 빈 문자열 + }, + }) + + // React Hook Form 과 tags 값 연결 + useEffect(() => { + setValue('tags', tags) + }, [tags, setValue]) + + // assignee 선택 시 드롭다운 닫기 + useEffect(() => { + if (selectedAssignee) { + setIsOpen(false) + } + }, [selectedAssignee]) + + // 이미지 파일 처리 + async function handleFileChange(e: React.ChangeEvent) { + const file = e.target.files?.[0] + if (!file) return + + uploadImage( + { columnId, file }, + { + onSuccess: ({ imageUrl }) => { + setValue('imageUrl', imageUrl) + setPreview(imageUrl) + }, + }, + ) + } + + // 폼 제출 핸들러 함수 + const { mutate: createCard, isPending } = usePostCard() + function onSubmit(data: CardFormData) { + const payload: CardFormData = { + ...data, + dashboardId: dashboardId, + columnId: columnId, + // tags: data.tags ?? [], + // imageUrl: data.imageUrl, + } + + if (!data.dueDate) delete payload.dueDate + if (!data.imageUrl || !preview) delete payload.imageUrl // delete로 아예 필드의 해당 key를 지워야, 서버가 "없음"으로 인식함.. + console.log('🌀', data.imageUrl) + console.log('submitted', payload) + createCard(payload) + onClose() + } + + // ✅ JSX + return ( +
+

할 일 생성

+ + {/* 담당자 입력 */} + ( + +
+ setIsOpen((prev) => !prev)} + value={selectedAssignee?.nickname ?? ''} + readOnly + className="Input-readOnly" + id="assigneeUserId" + type="text" + placeholder="담당자를 선택해 주세요" + /> + {isOpen && ( + + )} +
+ + )} + /> + + {/* 제목 입력 */} + + + + + {/* 설명 입력 */} + +