diff --git a/package.json b/package.json index 29989eb5..1b416705 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "check-task", - "version": "2.0.0", + "version": "3.0.0", "private": true, "scripts": { "prepare": "panda codegen", diff --git a/public/apple-icon.png b/public/apple-icon.png new file mode 100644 index 00000000..1d6d2553 Binary files /dev/null and b/public/apple-icon.png differ diff --git a/src/app/(main)/assignment/page.tsx b/src/app/(main)/assignment/page.tsx index 4cc1f18a..574b6dbf 100644 --- a/src/app/(main)/assignment/page.tsx +++ b/src/app/(main)/assignment/page.tsx @@ -1,18 +1,33 @@ 'use client'; -import { useState } from 'react'; +import { Suspense, useState } from 'react'; +import { useSearchParams } from 'next/navigation'; import { css } from 'styled-system/css'; import { AssignmentList } from '@/components/AssignmentList'; import { FolderFilterDropdown } from '@/components/FolderFilterDropdown'; import { useTaskList } from '@/features/assignment/hooks/useTaskList'; import { useMyInfo } from '@/hooks/queries/useMyInfo'; -export default function AssignmentPage() { +function AssignmentPageContent() { + const searchParams = useSearchParams(); + const folderIdParam = searchParams.get('folderId'); + const parsedFolderId = folderIdParam ? Number(folderIdParam) : NaN; + const initialFolderId = Number.isSafeInteger(parsedFolderId) + ? parsedFolderId + : null; + const { data: myInfo } = useMyInfo(); const folders = myInfo?.folders ?? []; + // 마이페이지에서 폴더 클릭 시 넘어온 folderId로 초기 필터 지정 + const [prevInitialFolderId, setPrevInitialFolderId] = useState(initialFolderId); const [selectedFolderIds, setSelectedFolderIds] = useState( - null, + () => (initialFolderId != null ? [initialFolderId] : null), ); + // 같은 페이지에서 URL의 folderId만 바뀌는 경우 대비, 렌더 중 동기화 + if (initialFolderId !== prevInitialFolderId) { + setPrevInitialFolderId(initialFolderId); + setSelectedFolderIds(initialFolderId != null ? [initialFolderId] : null); + } // 과제 목록 데이터 가져오기 const { data = [], isLoading } = useTaskList(selectedFolderIds); @@ -27,6 +42,7 @@ export default function AssignmentPage() {

내 과제

@@ -39,6 +55,14 @@ export default function AssignmentPage() { ); } +export default function AssignmentPage() { + return ( + + + + ); +} + // ======== 스타일 정의 ======== const headerStyle = css({ display: 'flex', diff --git a/src/app/(main)/page.tsx b/src/app/(main)/page.tsx index 226a3321..2bf46f3f 100644 --- a/src/app/(main)/page.tsx +++ b/src/app/(main)/page.tsx @@ -21,7 +21,7 @@ export default function Home() { const pageStyle = css( stack.raw({ - gap: '1.5rem', + gap: '1.25rem', marginX: 'auto', marginTop: '2rem', marginBottom: '3.75rem', diff --git a/src/app/auth/apple/callback/page.tsx b/src/app/auth/apple/callback/page.tsx new file mode 100644 index 00000000..dc7f5350 --- /dev/null +++ b/src/app/auth/apple/callback/page.tsx @@ -0,0 +1,58 @@ +'use client'; + +import { useCallback, useEffect, useRef } from 'react'; +import { useRouter } from 'next/navigation'; +import { css } from 'styled-system/css'; +import axiosInstance from '@/lib/axiosInstance'; +import { SOCIAL_AGREEMENT_REQUIRED_KEY } from '@/features/login/constants/socialAgreement'; +import { useAuthStore } from '@/stores/auth-store'; + +export default function AppleAuthCallbackPage() { + return ; +} + +function CallbackHandler() { + const router = useRouter(); + const login = useAuthStore((state) => state.login); + const isProcessing = useRef(false); + + const getAccessToken = useCallback(async () => { + try { + const response = await axiosInstance.post('/auth/refresh'); + const { accessToken } = response.data.data; + + if (accessToken) { + login(accessToken); + + const params = new URLSearchParams(window.location.search); + if (params.get('isNewUser') === 'true') { + window.sessionStorage.setItem(SOCIAL_AGREEMENT_REQUIRED_KEY, 'true'); + } + + router.replace('/'); + return; + } + + console.error('accessToken이 응답에 없습니다'); + router.replace('/login'); + } catch (error) { + console.error('Apple 로그인 토큰 발급 실패:', error); + router.replace('/login'); + } + }, [router, login]); + + useEffect(() => { + if (isProcessing.current) return; + isProcessing.current = true; + getAccessToken(); + }, [getAccessToken]); + + return
로그인 처리 중...
; +} + +const loadingStyle = css({ + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + height: '100vh', +}); diff --git a/src/app/auth/kakao/callback/page.tsx b/src/app/auth/kakao/callback/page.tsx index 8473e065..a8d9fa51 100644 --- a/src/app/auth/kakao/callback/page.tsx +++ b/src/app/auth/kakao/callback/page.tsx @@ -5,7 +5,7 @@ import { useRouter } from 'next/navigation'; import { css } from 'styled-system/css'; import axiosInstance from '@/lib/axiosInstance'; import { useAuthStore } from '@/stores/auth-store'; -import { KAKAO_AGREEMENT_REQUIRED_KEY } from '@/features/login/constants/kakaoAgreement'; +import { SOCIAL_AGREEMENT_REQUIRED_KEY } from '@/features/login/constants/socialAgreement'; export default function AuthCallbackPage() { return ; @@ -28,7 +28,7 @@ function CallbackHandler() { login(accessToken); // 토큰 저장 const params = new URLSearchParams(window.location.search); if (params.get('isNewUser') === 'true') { - window.sessionStorage.setItem(KAKAO_AGREEMENT_REQUIRED_KEY, 'true'); + window.sessionStorage.setItem(SOCIAL_AGREEMENT_REQUIRED_KEY, 'true'); } router.replace('/'); } else { diff --git a/src/components/DatePicker/CalendarModal.tsx b/src/components/DatePicker/CalendarModal.tsx index 0454d1db..4d55b1c5 100644 --- a/src/components/DatePicker/CalendarModal.tsx +++ b/src/components/DatePicker/CalendarModal.tsx @@ -2,7 +2,7 @@ import Calendar from 'react-calendar'; import 'react-calendar/dist/Calendar.css'; -import { css } from 'styled-system/css'; +import { css, cva } from 'styled-system/css'; import { flex } from 'styled-system/patterns'; import { RightIcon } from '../icons/RightIcon'; import { LeftIcon } from '../icons/LeftIcon'; @@ -10,18 +10,22 @@ import { useState } from 'react'; import { Button } from '../Button'; import { TimeToggle } from '../TimeToggle'; import { useAlertStore } from '@/stores/alert-store'; +import { CheckCircleIcon } from '../icons/CheckCircleIcon'; +import { token } from 'styled-system/tokens'; interface CalenderModalProps { onClose: () => void; - onSave: (date: Date, timeEnabled: boolean) => void; + onSave: (date: Date | null, timeEnabled: boolean) => void; // 초기 날짜를 받기 위함 - initialDate: Date; + initialDate: Date | null; // 세부 목록 날짜 선택시 이후 날짜 제한을 위해 추가 maxDate?: Date; // 시간 추가 토글 초기값 initialTimeEnabled?: boolean; // 시간 추가 토글 상태 변경 콜백 onTimeToggle?: (enabled: boolean) => void; + // 날짜 지정안함 옵션 노출 여부 + allowUnspecified?: boolean; } export default function CalendarModal({ @@ -31,19 +35,25 @@ export default function CalendarModal({ maxDate, initialTimeEnabled = false, onTimeToggle, + allowUnspecified = false, }: CalenderModalProps) { // 선택된 날짜 const { showAlert } = useAlertStore(); const [selectedDate, setSelectedDate] = useState(initialDate); + const [isUnspecifiedSelected, setIsUnspecifiedSelected] = useState( + allowUnspecified && !initialDate, + ); // 시간 추가 토글 상태 - const [timeEnabled, setTimeEnabled] = useState(initialTimeEnabled); + const [timeEnabled, setTimeEnabled] = useState( + initialDate ? initialTimeEnabled : false, + ); // 시간 입력 상태 — 모달을 다시 열 때 저장된 시간 복원 const [period, setPeriod] = useState<'오전' | '오후'>(() => { - if (!initialTimeEnabled) return '오전'; + if (!initialDate || !initialTimeEnabled) return '오전'; return initialDate.getHours() < 12 ? '오전' : '오후'; }); const [timeValue, setTimeValue] = useState(() => { - if (!initialTimeEnabled) return '00:00'; + if (!initialDate || !initialTimeEnabled) return '00:00'; const h = initialDate.getHours() % 12 || 12; const m = initialDate.getMinutes(); return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`; @@ -102,6 +112,7 @@ export default function CalendarModal({ }; const handleTimeToggle = (enabled: boolean) => { + if (!selectedDate) return; setTimeEnabled(enabled); onTimeToggle?.(enabled); }; @@ -113,9 +124,17 @@ export default function CalendarModal({ // 배열 말고 단일 선택만 고려 if (value instanceof Date || value === null) { setSelectedDate(value); + setIsUnspecifiedSelected(false); } }; + const handleSelectUnspecified = () => { + setSelectedDate(null); + setIsUnspecifiedSelected(true); + setTimeEnabled(false); + onTimeToggle?.(false); + }; + return (
+ {allowUnspecified && ( + + )}
- 시간 추가 - + + 시간 추가 + +
{timeEnabled && selectedDate && (
@@ -192,6 +234,13 @@ export default function CalendarModal({ variant='fillBlue' size='small' onClick={() => { + if (!selectedDate) { + if (allowUnspecified) { + onSave(null, false); + } + return; + } + if (selectedDate) { if (timeEnabled) { const [hStr, mStr] = timeValue.split(':'); @@ -406,19 +455,38 @@ const modalWrapper = css({ const timeSection = css({ display: 'flex', flexDirection: 'column', - gap: '0.5rem', + gap: '0.75rem', mt: '0.75rem', }); +const unspecifiedDateRow = flex({ + alignItems: 'center', + justifyContent: 'space-between', + bg: 'transparent', + border: 'none', + p: '0', + cursor: 'pointer', +}); + // 시간 추가 토글 행 const timeToggleRow = flex({ alignItems: 'center', justifyContent: 'space-between', }); -const timeToggleLabel = css({ - textStyle: 'body3.m', - color: 'gray.900', +const timeToggleLabel = cva({ + base: { + textStyle: 'body3.m', + color: 'gray.900', + }, + variants: { + disabled: { + true: { color: 'gray.400' }, + }, + }, + defaultVariants: { + disabled: false, + }, }); // 날짜 + 시간 입력 행 diff --git a/src/components/DatePicker/index.tsx b/src/components/DatePicker/index.tsx index 2675da67..7e9bbf61 100644 --- a/src/components/DatePicker/index.tsx +++ b/src/components/DatePicker/index.tsx @@ -6,17 +6,29 @@ import { css, cva } from 'styled-system/css'; import CalendarModal from './CalendarModal'; import { useClickOutside } from '@/hooks/useClickOutside'; -interface DatePickerProps { - value?: string | Date; - onChange?: (date: Date, timeEnabled: boolean) => void; +type BaseDatePickerProps = { maxDate?: string | Date; muted?: boolean; // 데이트 피커는 공용이니까 불리언으로 처리 showTimeDisplay?: boolean; // 시간 표시 여부 initialTimeEnabled?: boolean; // 시간 토글 초기 상태 -} +}; + +type RequiredDatePickerProps = BaseDatePickerProps & { + allowUnspecified?: false; + value?: string | Date; + onChange?: (date: Date, timeEnabled: boolean) => void; +}; + +type NullableDatePickerProps = BaseDatePickerProps & { + allowUnspecified: true; + value?: string | Date | null; + onChange?: (date: Date | null, timeEnabled: boolean) => void; +}; + +type DatePickerProps = RequiredDatePickerProps | NullableDatePickerProps; // 날짜 문자열로 온거 Date 객체로 변환 처리 -const parseDate = (value?: string | Date) => { +const parseDate = (value?: string | Date | null) => { if (!value) return null; if (value instanceof Date) return value; // 이미 시간이 포함된 문자열(YYYY-MM-DDTHH:mm:ss)은 그대로 파싱 @@ -31,12 +43,13 @@ export default function DatePicker({ muted = false, showTimeDisplay = false, initialTimeEnabled = false, + allowUnspecified = false, }: DatePickerProps) { // ======= 상태 정의 ======= const [isOpen, setIsOpen] = useState(false); - // 확정된 날짜 (기본값: 오늘) - const [confirmedDate, setConfirmedDate] = useState( - parseDate(value) ?? new Date(), + // 확정된 날짜 (기본값: 오늘, 날짜 미지정 허용 시 null 가능) + const [confirmedDate, setConfirmedDate] = useState( + parseDate(value) ?? (allowUnspecified ? null : new Date()), ); // 시간 추가 여부 const [timeEnabled, setTimeEnabled] = useState(initialTimeEnabled); @@ -48,15 +61,30 @@ export default function DatePicker({ useEffect(() => { const next = parseDate(value); if (next) { + // eslint-disable-next-line react-hooks/set-state-in-effect setConfirmedDate(next); + return; } - }, [value]); + + if (allowUnspecified) { + setConfirmedDate(null); + } + }, [allowUnspecified, value]); // 선택한 날짜 저장 - const handleSave = (date: Date, withTime: boolean) => { + const handleSave = (date: Date | null, withTime: boolean) => { setConfirmedDate(date); - setTimeEnabled(withTime); - onChange?.(date, withTime); + setTimeEnabled(date ? withTime : false); + + if (allowUnspecified) { + (onChange as NullableDatePickerProps['onChange'])?.( + date, + date ? withTime : false, + ); + } else if (date) { + (onChange as RequiredDatePickerProps['onChange'])?.(date, withTime); + } + setIsOpen(false); }; @@ -103,12 +131,12 @@ export default function DatePicker({ // 숫자부분도 클릭시 열리도록 onClick={() => setIsOpen(!isOpen)} > - {formatDate(confirmedDate)} + {confirmedDate ? formatDate(confirmedDate) : '지정안함'}
{/* 시간 추가 시 구분선 + 시간 텍스트 표시 (showTimeDisplay=true인 페이지에서만) */} - {showTimeDisplay && timeEnabled && ( + {showTimeDisplay && timeEnabled && confirmedDate && ( <>
diff --git a/src/components/FolderFilterDropdown.tsx b/src/components/FolderFilterDropdown.tsx index 18152a76..68ff6d89 100644 --- a/src/components/FolderFilterDropdown.tsx +++ b/src/components/FolderFilterDropdown.tsx @@ -13,17 +13,28 @@ import { useClickOutside } from '@/hooks/useClickOutside'; interface FolderFilterDropdownProps { folders?: Folder[]; + /** 진입 시 미리 선택해둘 폴더 id */ + initialFolderId?: number | null; onSelectionChange?: (selectedIds: number[] | null) => void; } export const FolderFilterDropdown = ({ folders = DUMMY_FOLDERS, + initialFolderId = null, onSelectionChange, }: FolderFilterDropdownProps) => { const [isOpen, setIsOpen] = useState(false); const containerRef = useClickOutside(() => setIsOpen(false)); + const [prevInitialFolderId, setPrevInitialFolderId] = useState(initialFolderId); // null = 전체 선택 (folders 로드 시점과 무관하게 항상 "전체"를 의미) - const [selectedIds, setSelectedIds] = useState | null>(null); + const [selectedIds, setSelectedIds] = useState | null>(() => + initialFolderId != null ? new Set([initialFolderId]) : null, + ); + // 같은 페이지에서 URL의 folderId만 바뀌는 경우 대비, 렌더 중 동기화 + if (initialFolderId !== prevInitialFolderId) { + setPrevInitialFolderId(initialFolderId); + setSelectedIds(initialFolderId != null ? new Set([initialFolderId]) : null); + } const isAllSelected = selectedIds === null; const isFolderSelected = (folderId: number) => isAllSelected || selectedIds.has(folderId); diff --git a/src/components/TimeToggle.tsx b/src/components/TimeToggle.tsx index 771df752..e86ba815 100644 --- a/src/components/TimeToggle.tsx +++ b/src/components/TimeToggle.tsx @@ -1,20 +1,28 @@ 'use client'; -import { cva, css } from 'styled-system/css'; +import { cva } from 'styled-system/css'; interface TimeToggleProps { checked: boolean; onChange: (checked: boolean) => void; + disabled?: boolean; } -export function TimeToggle({ checked, onChange }: TimeToggleProps) { +export function TimeToggle({ + checked, + onChange, + disabled = false, +}: TimeToggleProps) { return ( @@ -38,8 +46,11 @@ const trackStyle = cva({ true: { bg: 'primary' }, false: { bg: 'gray.300' }, }, + disabled: { + true: { bg: 'gray.100', cursor: 'not-allowed' }, + }, }, - defaultVariants: { checked: false }, + defaultVariants: { checked: false, disabled: false }, }); const thumbStyle = cva({ diff --git a/src/components/icons/DragIcon.tsx b/src/components/icons/DragIcon.tsx new file mode 100644 index 00000000..f8c2eaff --- /dev/null +++ b/src/components/icons/DragIcon.tsx @@ -0,0 +1,70 @@ +import { css, cx } from 'styled-system/css'; + +interface DragIconProps { + size?: number | string; + color?: string; + className?: string; +} + +export const DragIcon = ({ + size = 14, + color = 'gray.500', + className, +}: DragIconProps) => { + const iconClassName = cx(css({ color }), className); + + return ( + + + + + + + + + ); +}; diff --git a/src/constants/notices.ts b/src/constants/notices.ts index a9f54347..90a18882 100644 --- a/src/constants/notices.ts +++ b/src/constants/notices.ts @@ -18,6 +18,44 @@ export interface NoticeItem { } export const NOTICES: NoticeItem[] = [ + { + id: '4', + title: '📢 3차 릴리즈 안내', + intro: + 'CHECKTASK(채택) 서비스에 새로운 기능이 추가됐어요! 👏', + sections: [ + { + heading: '🔔 주요 업데이트', + subSections: [ + { + heading: '날짜를 지정하지 않는 기능이 추가됐어요.', + items: [ + '할 일은 맞는데 마감기한이 아직 정해지지 않은 일을 어떻게 해야될 지 곤란하셨나요?', + "이제부터 날짜 '지정안함' 기능을 이용해 관리해보세요.", + '홈 화면에서 지정안함 TASK를 달력으로 옮겨 쉽게 날짜를 정할 수 있어요.', + ], + }, + { + heading: '팀원 프로필을 확인할 수 있어요.', + items: [ + '프로필 이미지, 닉네임, 전화번호, 이메일 정보를 확인해 팀원을 구분하고, 연락을 취하세요.', + '전화번호를 공개하고 싶지 않으면 번호를 삭제해주세요.', + ], + }, + { + heading: '담당자를 여러명 설정할 수 있어요.', + items: [ + '세부 TASK의 담당자를 한 명만 선택할 수 있었는데, 이제는 여러명으로 설정할 수 있게 됐어요.', + '협업하기 더 쉬운 환경을 위해 발전하려고 노력하고 있어요.', + ], + }, + ], + }, + ], + footer: + '앞으로도 더 좋은 서비스를 위해 업데이트를 진행 할 예정이에요. 서비스를 주변에 많이 알려준다면 채택 팀에게 큰 힘이 됩니다!\n다음 릴리즈 때 봐요! 👋', + date: '2026.08.19', + }, { id: '3', title: '📢 2차 릴리즈 안내', diff --git a/src/features/assignment/components/TaskAddForm.tsx b/src/features/assignment/components/TaskAddForm.tsx index dbd71146..c0ce5857 100644 --- a/src/features/assignment/components/TaskAddForm.tsx +++ b/src/features/assignment/components/TaskAddForm.tsx @@ -25,18 +25,12 @@ const formatDeadline = (date: Date, withTime: boolean) => { return `${base}${DEFAULT_DEADLINE_TIME}`; }; -const todayAtMidnight = () => { - const d = new Date(); - d.setHours(0, 0, 0, 0); - return d; -}; - export const TaskAddForm = ({ taskId, maxDate }: TaskAddFormProps) => { const [isAdding, setIsAdding] = useState(false); const [taskName, setTaskName] = useState(''); // 선택된 날짜 상태 추가 - const [selectedDate, setSelectedDate] = useState(todayAtMidnight()); + const [selectedDate, setSelectedDate] = useState(null); const [timeEnabled, setTimeEnabled] = useState(false); // 세부과제 생성 훅 호출 const { mutate: createSubTask, isPending } = useCreateSubTask(taskId); @@ -46,7 +40,7 @@ export const TaskAddForm = ({ taskId, maxDate }: TaskAddFormProps) => { const handleCancelTask = () => { setIsAdding(false); setTaskName(''); - setSelectedDate(todayAtMidnight()); + setSelectedDate(null); setTimeEnabled(false); }; @@ -58,13 +52,15 @@ export const TaskAddForm = ({ taskId, maxDate }: TaskAddFormProps) => { } // 즉시 폼 닫기 + onMutate 낙관적 업데이트로 task 동시 노출 → 깜빡임 방지 - const deadline = formatDeadline(selectedDate, timeEnabled); + const deadline = selectedDate + ? formatDeadline(selectedDate, timeEnabled) + : null; setIsAdding(false); setTaskName(''); - setSelectedDate(todayAtMidnight()); + setSelectedDate(null); setTimeEnabled(false); - createSubTask({ title, deadline, isAlarm: true }); + createSubTask({ title, deadline, isAlarm: Boolean(deadline) }); }; return ( @@ -89,6 +85,7 @@ export const TaskAddForm = ({ taskId, maxDate }: TaskAddFormProps) => {
{ setSelectedDate(date); diff --git a/src/features/assignment/create/components/AddAssignmentTask.tsx b/src/features/assignment/create/components/AddAssignmentTask.tsx index 4095a041..960dcbf6 100644 --- a/src/features/assignment/create/components/AddAssignmentTask.tsx +++ b/src/features/assignment/create/components/AddAssignmentTask.tsx @@ -10,7 +10,7 @@ import { css } from 'styled-system/css'; export interface SubTaskInput { id: number; title: string; - endDate: Date; + endDate: Date | null; } interface AddAssignmentTaskProps { @@ -25,13 +25,12 @@ export const AddAssignmentTask = ({ maxDate, }: AddAssignmentTaskProps) => { const handleAddTask = () => { - const defaultEndDate = new Date(); onSubTasksChange([ ...subTasks, { id: Date.now(), title: '', - endDate: defaultEndDate, + endDate: null, }, ]); }; @@ -39,7 +38,7 @@ export const AddAssignmentTask = ({ const handleUpdate = ( id: number, field: 'title' | 'endDate', - value: string | Date, + value: string | Date | null, ) => { onSubTasksChange( subTasks.map((t) => (t.id === id ? { ...t, [field]: value } : t)), @@ -97,6 +96,7 @@ export const AddAssignmentTask = ({ })} > handleUpdate(task.id, 'endDate', d)} maxDate={maxDate ?? new Date()} diff --git a/src/features/assignment/create/components/CreateAssignmentForm.tsx b/src/features/assignment/create/components/CreateAssignmentForm.tsx index 1c66bb09..4cc03a60 100644 --- a/src/features/assignment/create/components/CreateAssignmentForm.tsx +++ b/src/features/assignment/create/components/CreateAssignmentForm.tsx @@ -77,7 +77,7 @@ export const CreateAssignmentForm = () => { .filter((t) => t.title.trim() !== '') .map((t) => ({ title: t.title.trim(), - endDate: formatDate(t.endDate), + endDate: t.endDate ? formatDate(t.endDate) : null, })), references: dataItems .filter((r) => r.type === 0) diff --git a/src/features/assignment/modify/components/ModifyAssignmentForm.tsx b/src/features/assignment/modify/components/ModifyAssignmentForm.tsx index 5af34cac..c3b7305e 100644 --- a/src/features/assignment/modify/components/ModifyAssignmentForm.tsx +++ b/src/features/assignment/modify/components/ModifyAssignmentForm.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { Button } from '@/components/Button'; import { Divider } from '@/components/Divider'; import { CheckboxHeader } from '../../create/components/CheckboxHeader'; // create있는거 그대로 사용 @@ -48,7 +48,7 @@ export const ModifyAssignmentForm = () => { const taskId = Number(searchParams?.get('taskId')); const { data } = useTeamTaskDetail(taskId); const { data: myInfo } = useMyInfo(); - const folders = myInfo?.folders ?? []; + const folders = useMemo(() => myInfo?.folders ?? [], [myInfo?.folders]); const initializedTaskIdRef = useRef(null); const updateTaskId = Number.isFinite(taskId) ? taskId : 0; const { mutateAsync: updateTask, isPending } = useUpdateTask(updateTaskId); @@ -68,6 +68,7 @@ export const ModifyAssignmentForm = () => { if (data.folderId == null && folders.length === 0) return; const resolvedFolderId = data.folderId ?? getFolderIdFromColor(data.foldercolor, folders); + // eslint-disable-next-line react-hooks/set-state-in-effect setAssignmentName(data.title ?? ''); setSelectedFolderId(resolvedFolderId); setDueDate(data.deadline ? new Date(`${data.deadline}T00:00:00`) : null); @@ -81,7 +82,7 @@ export const ModifyAssignmentForm = () => { dueDate: task.deadline, status: task.status, isAlarm: task.isAlarm, - assigneeId: task.assigneeId ?? 0, + assigneeId: task.assignees?.[0]?.userId ?? 0, })); const initialDataItems = data?.references?.map((ref, index) => ({ @@ -171,7 +172,7 @@ export const ModifyAssignmentForm = () => { .filter((task) => task.title.trim() !== '') .map((task) => ({ title: task.title.trim(), - endDate: task.dueDate ?? '', + endDate: task.dueDate ?? null, status: ensureStatus(task.status), isAlarm: task.isAlarm ?? false, assigneeId: diff --git a/src/features/assignment/modify/components/ModifyAssignmentTask.tsx b/src/features/assignment/modify/components/ModifyAssignmentTask.tsx index 650754f2..e1c2f078 100644 --- a/src/features/assignment/modify/components/ModifyAssignmentTask.tsx +++ b/src/features/assignment/modify/components/ModifyAssignmentTask.tsx @@ -12,7 +12,7 @@ import { ConfirmDeleteAssignmentDataModal } from '../../components/ConfirmDelete export interface ModifyTaskItem { id: number; title: string; - dueDate?: string; // 서버에서 strig 형태로 받음 + dueDate?: string | null; // 서버에서 string 형태로 받음 status?: 'PROGRESS' | 'COMPLETED'; isAlarm?: boolean; assigneeId?: number; @@ -98,7 +98,16 @@ export const ModifyAssignmentTask = ({ }; // 마감기한 변경 핸들러 - const handleDateChange = (id: number, date: Date) => { + const handleDateChange = (id: number, date: Date | null) => { + if (!date) { + setTasks((prev) => + prev.map((task) => + task.id === id ? { ...task, dueDate: null } : task, + ), + ); + return; + } + // 받은 date를 로컬 YYYY-MM-DD 형태로 // 기존 toISOString 쓰면 UTC 기준이라서 전날이 나올 수 있어 변경 const yyyy = date.getFullYear(); @@ -168,6 +177,7 @@ export const ModifyAssignmentTask = ({ }} /> handleDateChange(task.id, date)} maxDate={maxDate ?? undefined} diff --git a/src/features/assignment/personal/components/PersonalTaskList.tsx b/src/features/assignment/personal/components/PersonalTaskList.tsx index c2289dbf..8ef2807f 100644 --- a/src/features/assignment/personal/components/PersonalTaskList.tsx +++ b/src/features/assignment/personal/components/PersonalTaskList.tsx @@ -15,7 +15,7 @@ import { CloseIcon } from '@/components/icons/CloseIcon'; export interface PersonalTaskItem { id: number; title: string; - deadline: string; + deadline: string | null; isAlarm: boolean; status: SubTaskStatus; } @@ -48,7 +48,7 @@ const formatDate = (date: Date, withTime: boolean) => { return `${base}${DEFAULT_DEADLINE_TIME}`; }; -const hasTimeSet = (deadline?: string | Date): boolean => { +const hasTimeSet = (deadline?: string | Date | null): boolean => { if (typeof deadline !== 'string') return false; return deadline.includes('T') && !deadline.endsWith(':59'); }; @@ -83,9 +83,15 @@ export const PersonalTaskList = ({ const animateTimeoutRef = useRef>(undefined); // 달력 날짜 변경 시 호출 핸들러 - const handleDeadlineChange = (subTaskId: number) => (date: Date, timeEnabled: boolean) => { - mutateDeadline({ subTaskId, endDate: formatDate(date, timeEnabled) }); - }; + const handleDeadlineChange = + (subTaskId: number) => (date: Date | null, timeEnabled: boolean) => { + const endDate = date ? formatDate(date, timeEnabled) : null; + if (endDate === null) { + setAlarmStateMap((prev) => ({ ...prev, [subTaskId]: false })); + } + + mutateDeadline({ subTaskId, endDate }); + }; // 체크박스 선택 시 호출 핸들러 const handleStatusChange = (subTaskId: number, isChecked: boolean) => { @@ -171,6 +177,7 @@ export const PersonalTaskList = ({ >
{ - const today = new Date(); - if (!maxDate) return today; - const max = typeof maxDate === 'string' ? new Date(maxDate) : maxDate; - if (Number.isNaN(max.getTime())) return today; - return today > max ? max : today; -}; - export const AddTaskButton = ({ taskId, maxDate }: AddTaskButtonProps) => { const [isInputVisible, setIsInputVisible] = useState(false); const [value, setValue] = useState(''); - const [deadline, setDeadline] = useState(() => - getDefaultDeadline(maxDate), - ); + const [deadline, setDeadline] = useState(null); const [timeEnabled, setTimeEnabled] = useState(false); - const isAlarm = true; const inputRef = useRef(null); const { mutate: createSubTask, isPending } = useCreateSubTask(taskId); const showAlert = useAlertStore((state) => state.showAlert); @@ -54,7 +43,7 @@ export const AddTaskButton = ({ taskId, maxDate }: AddTaskButtonProps) => { const closeAndClear = () => { setIsInputVisible(false); setValue(''); - setDeadline(getDefaultDeadline(maxDate)); + setDeadline(null); setTimeEnabled(false); }; @@ -67,8 +56,8 @@ export const AddTaskButton = ({ taskId, maxDate }: AddTaskButtonProps) => { createSubTask( { title, - deadline: formatDeadline(deadline, timeEnabled), - isAlarm, + deadline: deadline ? formatDeadline(deadline, timeEnabled) : null, + isAlarm: Boolean(deadline), }, { onSuccess: closeAndClear, @@ -102,6 +91,7 @@ export const AddTaskButton = ({ taskId, maxDate }: AddTaskButtonProps) => { onChange={(e) => setValue(e.target.value)} /> { setDeadline(d); @@ -127,7 +117,7 @@ export const AddTaskButton = ({ taskId, maxDate }: AddTaskButtonProps) => { type='button' className={addTaskButtonStyle} onClick={() => { - setDeadline(getDefaultDeadline(maxDate)); + setDeadline(null); setIsInputVisible(true); }} > diff --git a/src/features/assignment/team/components/TeamMemberManageModal.tsx b/src/features/assignment/team/components/TeamMemberManageModal.tsx index 365bc7bb..0bd25ec9 100644 --- a/src/features/assignment/team/components/TeamMemberManageModal.tsx +++ b/src/features/assignment/team/components/TeamMemberManageModal.tsx @@ -1,11 +1,13 @@ 'use client'; import { useEffect, useState } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; import { css } from 'styled-system/css'; import { TeamMemberManageModalItem, type MemberRole, } from './TeamMemberManageModalItem'; +import { TeamMemberProfileModalItem } from './TeamMemberProfileModalItem'; import { Divider } from '@/components/Divider'; import { Input } from '@/components/TextField'; import { useCreateInvitationLink } from '@/hooks/mutations/useCreateInvitationLink'; @@ -13,7 +15,9 @@ import { useExpelTaskMember } from '@/hooks/mutations/useExpelTaskMember'; import { useTaskMembers } from '@/hooks/queries/useTaskMembers'; import { useUpdateMemberRole } from '@/hooks/mutations/useUpdateMemberRole'; import { useMyInfo } from '@/hooks/queries/useMyInfo'; +import { getTaskMemberProfile } from '@/services/task'; import { useAlertStore } from '@/stores/alert-store'; +import { useModalStore } from '@/stores/modal-store'; interface TeamMemberManageModalProps { taskId: number; @@ -59,6 +63,8 @@ export const TeamMemberManageModal = ({ const [roleError, setRoleError] = useState(null); const { data: myInfo } = useMyInfo(); const { showAlert } = useAlertStore(); + const { openModal } = useModalStore(); + const queryClient = useQueryClient(); const { data: membersData } = useTaskMembers(taskId); const { mutateAsync: createInvitation, isPending } = useCreateInvitationLink(taskId); @@ -66,6 +72,19 @@ export const TeamMemberManageModal = ({ const { mutate: expelMember } = useExpelTaskMember(taskId); const members = membersData ?? []; + + useEffect(() => { + // 목록이 보이는 동안 미리 프로필을 캐싱해둬서, 클릭 시 깜빡임 없이 바로 보이도록 함 + members.forEach((member) => { + if (member.userId == null) return; + queryClient.prefetchQuery({ + queryKey: ['taskMemberProfile', taskId, member.userId], + queryFn: () => getTaskMemberProfile(taskId, member.userId as number), + }); + }); + // members 배열은 매 렌더마다 새로 생성되므로 memberId 조합으로만 재실행 여부 판단 + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [taskId, members.map((m) => m.memberId).join(',')]); const currentUserId = myInfo?.user?.id; const myNickname = myInfo?.user?.nickname; // API가 user_id를 주면 숫자 비교(문자열 응답 대비 Number() 사용), 없으면 닉네임으로 현재 사용자 행 보완 @@ -123,6 +142,22 @@ export const TeamMemberManageModal = ({ ); }; + const handleProfileClick = (member: (typeof members)[number]) => { + openModal({ + title: '팀원 목록', + headerType: 'withBack', + onLeftClick: () => + openModal({ + title: '팀원 관리', + headerType: 'withClose', + content: , + }), + content: ( + + ), + }); + }; + const handleExpelMember = (memberId: number, memberName: string) => { const confirmed = window.confirm( `${memberName}을(를) 팀에서 삭제하시겠습니까?`, @@ -166,6 +201,7 @@ export const TeamMemberManageModal = ({ onExpelMember={() => handleExpelMember(member.memberId, member.name) } + onProfileClick={() => handleProfileClick(member)} /> )) )} @@ -201,6 +237,7 @@ const modalContentStyle = css({ display: 'flex', flexDirection: 'column', gap: '1.75rem', + width: '27.25rem', }); const modalContentItemStyle = css({ diff --git a/src/features/assignment/team/components/TeamMemberManageModalItem.tsx b/src/features/assignment/team/components/TeamMemberManageModalItem.tsx index 5364ff3f..7d5caacc 100644 --- a/src/features/assignment/team/components/TeamMemberManageModalItem.tsx +++ b/src/features/assignment/team/components/TeamMemberManageModalItem.tsx @@ -24,6 +24,7 @@ interface TeamMemberManageModalItemProps { ) => void; onDeleteMember?: () => void; onExpelMember?: () => void; + onProfileClick?: () => void; } export const TeamMemberManageModalItem = ({ @@ -37,13 +38,18 @@ export const TeamMemberManageModalItem = ({ canChangeRole, onRoleChange, onExpelMember, + onProfileClick, }: TeamMemberManageModalItemProps) => { const roleLabel = isCurrentUser ? `${role}(you)` : role; const showDropdown = !isCurrentUser && canChangeRole; return (
-
+
+
{showDropdown ? ( @@ -95,7 +95,7 @@ const modalContentItemStyle = css({ display: 'flex', justifyContent: 'space-between', alignItems: 'center', - width: '27.25rem', + width: '100%', }); const modalContentItemTitleStyle = css({ @@ -104,6 +104,10 @@ const modalContentItemTitleStyle = css({ gap: '0.75rem', display: 'flex', alignItems: 'center', + background: 'none', + border: 'none', + padding: 0, + cursor: 'pointer', }); const modalContentItemTitleIconStyle = css({ @@ -118,11 +122,6 @@ const modalContentItemTitleIconStyle = css({ overflow: 'hidden', }); -const profilePlaceholderTextStyle = css({ - textStyle: 'body4.m', - color: 'gray.600', -}); - const modalContentItemMemberStyle = css({ display: 'flex', alignItems: 'center', diff --git a/src/features/assignment/team/components/TeamMemberProfileModalItem.tsx b/src/features/assignment/team/components/TeamMemberProfileModalItem.tsx new file mode 100644 index 00000000..341432c5 --- /dev/null +++ b/src/features/assignment/team/components/TeamMemberProfileModalItem.tsx @@ -0,0 +1,136 @@ +import { css } from 'styled-system/css'; +import { useTaskMemberProfile } from '@/hooks/queries/useTaskMemberProfile'; + +// 백엔드가 연락처 미설정 사용자에게 null 대신 내려주는 안내 문구 +const PHONE_PLACEHOLDER = '전화번호를 입력해 주세요.'; + +interface TeamMemberProfileModalItemProps { + taskId: number; + userId?: number; +} + +export const TeamMemberProfileModalItem = ({ + taskId, + userId, +}: TeamMemberProfileModalItemProps) => { + const { data: profile } = useTaskMemberProfile(taskId, userId); + const phoneNum = + profile?.phoneNum && profile.phoneNum !== PHONE_PLACEHOLDER + ? profile.phoneNum + : ''; + + return ( +
+
+
+

{profile?.nickname ?? ''}

+
+ +
+ +
+

기본정보

+
+
+ 연락처 + {phoneNum || '-'} +
+
+ 이메일 + {profile?.email || '-'} +
+
+
+
+ ); +}; + +const cardStyle = css({ + display: 'flex', + alignItems: 'center', + gap: '1.75rem', + padding: '1.25rem 1.75rem', + marginTop: '1.75rem', + marginBottom: '1.125rem', + bg: 'gray.0', + borderRadius: '0.75rem', + boxShadow: '0px 1px 4px rgba(0, 0, 0, 0.08)', +}); + +const profileColumnStyle = css({ + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + gap: '0.75rem', +}); + +const avatarStyle = css({ + width: '5rem', + height: '5rem', + minWidth: '5rem', + borderRadius: 'full', + bg: 'blue.100', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + overflow: 'hidden', +}); + +const nameStyle = css({ + textStyle: 'body2.m', + color: 'gray.900', + textAlign: 'center', +}); + +const verticalDividerStyle = css({ + width: '0.0625rem', + height: '6.25rem', + bg: 'gray.200', +}); + +const infoColumnStyle = css({ + display: 'flex', + flexDirection: 'column', + gap: '1rem', + flex: 1, +}); + +const infoTitleStyle = css({ + textStyle: 'body2.m', + color: 'gray.900', +}); + +const infoRowsStyle = css({ + display: 'flex', + flexDirection: 'column', + gap: '0.75rem', +}); + +const infoRowStyle = css({ + display: 'flex', + alignItems: 'center', + gap: '1.5rem', +}); + +const infoLabelStyle = css({ + textStyle: 'body2.r', + color: 'gray.400', + whiteSpace: 'nowrap', + flexShrink: 0, +}); + +const infoValueStyle = css({ + textStyle: 'body2.m', + color: 'gray.700', +}); diff --git a/src/features/assignment/team/components/TeamTaskList.tsx b/src/features/assignment/team/components/TeamTaskList.tsx index d193e348..029b9536 100644 --- a/src/features/assignment/team/components/TeamTaskList.tsx +++ b/src/features/assignment/team/components/TeamTaskList.tsx @@ -43,7 +43,7 @@ const getCommentId = ( const DEFAULT_DEADLINE_TIME = 'T23:59:59'; -const hasTimeSet = (deadline?: string | Date): boolean => { +const hasTimeSet = (deadline?: string | Date | null): boolean => { if (typeof deadline !== 'string') return false; return deadline.includes('T') && !deadline.endsWith(':59'); }; @@ -111,9 +111,9 @@ const TeamTaskList = ({ const currentUserId = myInfo?.user?.id; const myNickname = myInfo?.user?.nickname ?? ''; const teamMembersForDropdown = taskMembers - .filter((m) => m.memberId !== currentUserId && m.name !== myNickname) + .filter((m) => m.userId !== currentUserId && m.name !== myNickname) .map((m) => ({ - id: m.memberId, + id: m.userId ?? m.memberId, nickname: m.name, profileImage: m.profileImage ?? undefined, })); @@ -143,8 +143,8 @@ const TeamTaskList = ({ }; }, [clearPendingAndRefetch]); - const handleSelectAssignee = (subTaskId: number, assigneeId: number | null) => { - updateAssignee({ taskId, subTaskId, assigneeId }); + const handleSelectAssignee = (subTaskId: number, assigneeIds: number[]) => { + updateAssignee({ taskId, subTaskId, assigneeIds }); }; // createdAt을 yy.mm.dd, hh:mm 으로 분리 (각각 0.25rem 간격용) @@ -218,8 +218,17 @@ const TeamTaskList = ({ return `${base}${DEFAULT_DEADLINE_TIME}`; }; - const handleDeadlineChange = (subTaskId: number, date: Date, timeEnabled: boolean) => { - mutateDeadline({ taskId, subTaskId, endDate: toYYYYMMDD(date, timeEnabled) }); + const handleDeadlineChange = ( + subTaskId: number, + date: Date | null, + timeEnabled: boolean, + ) => { + const endDate = date ? toYYYYMMDD(date, timeEnabled) : null; + if (endDate === null) { + setAlarmStateMap((prev) => ({ ...prev, [subTaskId]: false })); + } + + mutateDeadline({ taskId, subTaskId, endDate }); }; // 알림 설정 변경 핸들러 @@ -464,6 +473,7 @@ const TeamTaskList = ({ style={isEditMode ? { opacity: 0.4, pointerEvents: 'none' } : undefined} > handleDeadlineChange(task.subTaskId, date, timeEnabled) @@ -495,13 +505,11 @@ const TeamTaskList = ({ >

담당:

{ - if (assigneeId !== undefined) - handleSelectAssignee(task.subTaskId, assigneeId); - }} + onSelectMember={(assigneeIds) => + handleSelectAssignee(task.subTaskId, assigneeIds) + } />
{isEditMode && ( @@ -823,11 +831,14 @@ const managerContainerStyle = css({ display: 'flex', alignItems: 'center', gap: '0.5rem', + flex: 1, + minWidth: 0, }); const managerLabelStyle = css({ textStyle: 'body3.r', color: 'gray.600', + flexShrink: 0, }); // 댓글 섹션 스타일 diff --git a/src/features/assignment/team/components/TeamTaskManager.tsx b/src/features/assignment/team/components/TeamTaskManager.tsx index 0d8a5f6f..e89fafcc 100644 --- a/src/features/assignment/team/components/TeamTaskManager.tsx +++ b/src/features/assignment/team/components/TeamTaskManager.tsx @@ -1,118 +1,88 @@ 'use client'; -import { useState, useEffect } from 'react'; -import { css, cva } from 'styled-system/css'; +import { useState } from 'react'; +import { css } from 'styled-system/css'; import { useClickOutside } from '@/hooks/useClickOutside'; import { useMyInfo } from '@/hooks/queries/useMyInfo'; +import type { TaskDetailSubTaskAssignee } from '@/types/task'; import { TeamTaskManagerDropdown } from './TeamTaskManagerDropdown'; import type { TeamTaskManagerDropdownMember } from './TeamTaskManagerDropdown'; +import { TeamTaskManagerChipGroup } from './TeamTaskManagerChipGroup'; export type TeamTaskManagerMember = TeamTaskManagerDropdownMember; -const getDisplayFromProps = ( - manager?: string, - profileImage?: string, -): { name: string; profileImage?: string } => { - const value = (manager ?? '').trim(); - const isEmpty = - value === '' || - value === 'none' || - value.toUpperCase() === 'PENDING' || - value === '미지정'; - return isEmpty - ? { name: 'none', profileImage: undefined } - : { name: manager!, profileImage }; -}; - interface TeamTaskManagerProps { - manager?: string; - profileImage?: string; - /** 드롭다운에 표시할 팀원 목록 (내 정보 제외, id 있으면 과제 수정 API에 사용) */ + assignees?: TaskDetailSubTaskAssignee[]; + /** 드롭다운에 표시할 팀원 목록 (내 정보 제외) */ members?: TeamTaskManagerMember[]; - /** 선택 시 (닉네임, 담당자 사용자 ID). null이면 담당자 없음(none) */ - onSelectMember?: (nickname: string, assigneeId?: number | null) => void; + /** 선택된 담당자 user id 목록. 빈 배열이면 담당자 없음(none) */ + onSelectMember?: (assigneeIds: number[]) => void; } export const TeamTaskManager = ({ - manager, - profileImage, + assignees = [], members = [], onSelectMember, }: TeamTaskManagerProps) => { const [isOpen, setIsOpen] = useState(false); - const [display, setDisplay] = useState(() => - getDisplayFromProps(manager, profileImage), + const [prevAssignees, setPrevAssignees] = useState(assignees); + // 현재 선택되어 있는 담당자들의 userId 목록 + const [selectedIds, setSelectedIds] = useState(() => + assignees.map((a) => a.userId), ); const { data: myInfo } = useMyInfo(); const ref = useClickOutside(() => setIsOpen(false)); - useEffect(() => { - setDisplay(getDisplayFromProps(manager, profileImage)); - }, [manager, profileImage]); - - const hasManager = display.name !== 'none'; - const isEmpty = display.name === 'none'; + // assignees prop이 바뀌면(서버 재조회 등) 렌더 중 즉시 로컬 선택 상태를 동기화 + if (assignees !== prevAssignees) { + setPrevAssignees(assignees); + setSelectedIds(assignees.map((a) => a.userId)); + } const myNickname = myInfo?.user?.nickname ?? '나'; - - const getProfileImageForNickname = (nickname: string): string | undefined => { - if (nickname === myNickname) return myInfo?.user?.profileImage ?? undefined; - return members.find((m) => m.nickname === nickname)?.profileImage; + const myId = myInfo?.user?.id ?? 0; + + const handleToggleMember = (id: number) => { + const next = selectedIds.includes(id) + ? selectedIds.filter((i) => i !== id) + : [...selectedIds, id]; + setSelectedIds(next); + onSelectMember?.(next); }; - const getAssigneeIdForNickname = (nickname: string): number | undefined => { - if (nickname === myNickname) return myInfo?.user?.id; - return members.find((m) => m.nickname === nickname)?.id; + const handleClearAll = () => { + setSelectedIds([]); + onSelectMember?.([]); }; - const handleSelect = (nickname: string) => { - if (nickname === 'none') { - setDisplay({ name: 'none', profileImage: undefined }); - onSelectMember?.('none', null); - setIsOpen(false); - return; - } - setDisplay({ - name: nickname, - profileImage: getProfileImageForNickname(nickname), - }); - onSelectMember?.(nickname, getAssigneeIdForNickname(nickname)); - setIsOpen(false); - }; + const canonicalOrder = [ + { id: myId, nickname: myNickname, profileImage: myInfo?.user?.profileImage }, + ...members, + ]; + const chipMembers = canonicalOrder + .filter((m) => selectedIds.includes(m.id)) + .map(({ nickname, profileImage }) => ({ + nickname, + profileImage: profileImage ?? undefined, + })); return (
- + /> {isOpen && ( )}
@@ -121,43 +91,6 @@ export const TeamTaskManager = ({ const wrapperStyle = css({ position: 'relative', - display: 'inline-block', -}); - -const teamTaskManagerStyle = cva({ - base: { - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - gap: '0.375rem', - borderRadius: '2.5rem', - pl: '0.5rem', - pr: '0.75rem', - py: '0.5rem', - border: 'none', - cursor: 'pointer', - outline: 'none', - }, - variants: { - empty: { - true: { bg: 'gray.100' }, - false: { bg: 'blue.50' }, - }, - }, - defaultVariants: { empty: false }, -}); - -const teamTaskManagerIconStyle = cva({ - base: { - width: '1.375rem', - height: '1.375rem', - borderRadius: 'full', - }, - variants: { - empty: { - true: { bg: 'gray.200' }, - false: { bg: 'blue.200' }, - }, - }, - defaultVariants: { empty: false }, + flex: 1, + minWidth: 0, }); diff --git a/src/features/assignment/team/components/TeamTaskManagerChipGroup.tsx b/src/features/assignment/team/components/TeamTaskManagerChipGroup.tsx new file mode 100644 index 00000000..28e9fcf8 --- /dev/null +++ b/src/features/assignment/team/components/TeamTaskManagerChipGroup.tsx @@ -0,0 +1,241 @@ +'use client'; + +import { useLayoutEffect, useRef, useState } from 'react'; +import { css, cva } from 'styled-system/css'; + +export interface TeamTaskManagerChipMember { + nickname: string; + profileImage?: string; +} + +interface TeamTaskManagerChipGroupProps { + /** 표시할 담당자 목록 (표시 우선순위 순서). 빈 배열이면 "없음" 상태로 렌더링 */ + members: TeamTaskManagerChipMember[]; + isOpen: boolean; + onClick: () => void; +} + +const CHIP_GAP = 2; +const LABEL_GAP = 4; + +// 화면에 표시 가능한 담당자 수를 계산하는 함수 +const computeVisibleCount = ( + chipWidths: number[], + overflowWidth: number, + maxWidth: number, +): number => { + const total = chipWidths.length; + const sumAll = + chipWidths.reduce((a, b) => a + b, 0) + CHIP_GAP * Math.max(total - 1, 0); + if (sumAll <= maxWidth) return total; + + for (let k = total - 1; k >= 0; k--) { + if (k === 0) return 0; + const chipsWidth = + chipWidths.slice(0, k).reduce((a, b) => a + b, 0) + CHIP_GAP * (k - 1); + if (chipsWidth + LABEL_GAP + overflowWidth <= maxWidth) return k; + } + return 0; +}; + +export const TeamTaskManagerChipGroup = ({ + members, + isOpen, + onClick, +}: TeamTaskManagerChipGroupProps) => { + // "담당:" 라벨을 제외한 실제 남는 폭은 사이드바 여부와 무관하게 고정이므로, + // 마운트 시 한 번만 측정해서 그 값을 계속 사용하도록 + const [maxWidth, setMaxWidth] = useState(0); + const containerRef = useRef(null); + const [visibleCount, setVisibleCount] = useState(members.length); + // 각 담당자 chip의 폭을 측정하기 위한 ref + const chipRefs = useRef<(HTMLSpanElement | null)[]>([]); + // 외 N 라벨의 폭을 측정하기 위한 ref + const overflowMeasureRef = useRef(null); + + useLayoutEffect(() => { + if (containerRef.current) { + setMaxWidth(containerRef.current.offsetWidth); + } + }, []); + + useLayoutEffect(() => { + if (members.length === 0 || maxWidth === 0) return; + + const widths = chipRefs.current + .slice(0, members.length) + .map((el) => el?.offsetWidth ?? 0); + const overflowWidth = overflowMeasureRef.current?.offsetWidth ?? 0; + + setVisibleCount(computeVisibleCount(widths, overflowWidth, maxWidth)); + }, [members, maxWidth]); + + if (members.length === 0) { + return ( + + ); + } + + const visibleMembers = members.slice(0, visibleCount); + const overflowCount = members.length - visibleCount; + + return ( + + ); +}; + +const chipGroupWrapperStyle = css({ + display: 'flex', + alignItems: 'center', + gap: `${LABEL_GAP}px`, + width: '100%', + border: 'none', + background: 'none', + padding: 0, + cursor: 'pointer', + outline: 'none', + overflow: 'hidden', +}); + +const chipRowStyle = css({ + display: 'flex', + alignItems: 'center', + gap: `${CHIP_GAP}px`, + minWidth: 0, +}); + +// 너비 측정 전용으로 숨겨진 DOM. 화면에 표시되지 않음 +const hiddenMeasureStyle = css({ + position: 'absolute', + top: '-9999px', + left: '-9999px', + visibility: 'hidden', + pointerEvents: 'none', + display: 'flex', + alignItems: 'center', + gap: `${CHIP_GAP}px`, + whiteSpace: 'nowrap', +}); + +const overflowLabelStyle = css({ + textStyle: 'body4.m', + color: 'blue.200', + whiteSpace: 'nowrap', + flexShrink: 0, +}); + +export const teamTaskManagerStyle = cva({ + base: { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + gap: '0.375rem', + borderRadius: '2.5rem', + pl: '0.5rem', + pr: '0.75rem', + py: '0.5rem', + flexShrink: 0, + }, + variants: { + // 담당자가 있을 때랑 없을 때 + empty: { + true: { bg: 'gray.100' }, + false: { bg: 'blue.50' }, + }, + }, + defaultVariants: { empty: false }, +}); + +export const teamTaskManagerIconStyle = cva({ + base: { + width: '1.375rem', + height: '1.375rem', + borderRadius: 'full', + flexShrink: 0, + }, + variants: { + empty: { + true: { bg: 'gray.200' }, + false: { bg: 'blue.200' }, + }, + }, + defaultVariants: { empty: false }, +}); + +const chipNameStyle = cva({ + base: { + textStyle: 'body2.r', + whiteSpace: 'nowrap', + maxWidth: '3.5rem', + overflow: 'hidden', + textOverflow: 'ellipsis', + }, + variants: { + empty: { + true: { color: 'gray.300' }, + false: { color: 'gray.800' }, + }, + }, + defaultVariants: { empty: false }, +}); diff --git a/src/features/assignment/team/components/TeamTaskManagerDropdown.tsx b/src/features/assignment/team/components/TeamTaskManagerDropdown.tsx index e043f007..b3dcf39a 100644 --- a/src/features/assignment/team/components/TeamTaskManagerDropdown.tsx +++ b/src/features/assignment/team/components/TeamTaskManagerDropdown.tsx @@ -1,69 +1,92 @@ 'use client'; -import { css } from 'styled-system/css'; +import { Fragment } from 'react'; +import { css, cva } from 'styled-system/css'; +import { CheckMark } from '@/components/icons/CheckMark'; export interface TeamTaskManagerDropdownMember { - id?: number; + id: number; nickname: string; profileImage?: string; } interface TeamTaskManagerDropdownProps { + myId: number; myNickname: string; myProfileImage?: string; members: TeamTaskManagerDropdownMember[]; - selectedManager?: string; - onSelect: (nickname: string) => void; + selectedIds: number[]; + onToggle: (id: number) => void; + onClearAll: () => void; } export const TeamTaskManagerDropdown = ({ + myId, myNickname, myProfileImage, members, - selectedManager, - onSelect, + selectedIds, + onToggle, + onClearAll, }: TeamTaskManagerDropdownProps) => { - return ( -
- - , + - {members.map((m) => ( + {myNickname}(you) + + {selectedIds.includes(myId) && ( + + )} + , + ...members.map((m) => { + const isSelected = selectedIds.includes(m.id); + return ( + ); + }), + ]; + + return ( +
+ {rows.map((row, index) => ( + + {row} + {index < rows.length - 1 && } + ))}
); @@ -91,29 +133,46 @@ const dropdownStyle = css({ zIndex: 10, width: '13.375rem', marginTop: '0.25rem', + padding: '0.5rem', bg: 'bg', borderRadius: '0.5rem', boxShadow: '0px 4px 12px rgba(0, 0, 0, 0.12)', overflow: 'hidden', }); -const dropdownItemStyle = css({ - display: 'flex', - alignItems: 'center', - gap: '0.5rem', - width: '100%', - py: '0.75rem', - pl: '1rem', - pr: '1rem', - border: 'none', - background: 'transparent', - cursor: 'pointer', - textAlign: 'left', - borderBottom: '0.0625rem solid', - borderBottomColor: 'gray.100', - '&:last-child': { - borderBottom: 'none', +const dropdownItemStyle = cva({ + base: { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + gap: '0.75rem', + width: '100%', + padding: '0.5rem', + borderRadius: '0.5rem', + border: 'none', + background: 'transparent', + cursor: 'pointer', + textAlign: 'left', + }, + variants: { + selected: { + true: { bg: 'blue.50' }, + false: {}, + }, }, + defaultVariants: { selected: false }, +}); + +const dividerStyle = css({ + display: 'block', + height: '0.0625rem', + bg: 'gray.100', + margin: '0.5rem -0.5rem', +}); + +const checkIconStyle = css({ + flexShrink: 0, + marginLeft: '0.25rem', }); const noneCircleStyle = css({ @@ -132,7 +191,21 @@ const profileCircleStyle = css({ flexShrink: 0, }); -const nicknameStyle = css({ - textStyle: 'body3.r', - color: 'gray.800', +const nicknameStyle = cva({ + base: { + textStyle: 'body3.r', + color: 'gray.800', + flex: 1, + minWidth: 0, + overflow: 'hidden', + whiteSpace: 'nowrap', + textOverflow: 'ellipsis', + }, + variants: { + selected: { + true: { textStyle: 'body3.m', color: 'blue.500' }, + false: {}, + }, + }, + defaultVariants: { selected: false }, }); diff --git a/src/features/assignment/team/components/hooks/useUpdateSubTaskAssignee.ts b/src/features/assignment/team/components/hooks/useUpdateSubTaskAssignee.ts index 94a7bc56..784d20d2 100644 --- a/src/features/assignment/team/components/hooks/useUpdateSubTaskAssignee.ts +++ b/src/features/assignment/team/components/hooks/useUpdateSubTaskAssignee.ts @@ -7,7 +7,8 @@ import { updateSubTaskAssignee } from '@/services/subtask'; interface UpdateSubTaskAssigneeInput { taskId: number; subTaskId: number; - assigneeId: number | null; + /** 담당자 user id 목록. 빈 배열이면 담당자 없음(none) */ + assigneeIds: number[]; } /** 팀 과제 세부 TASK 담당자 설정 (소켓 우선, 미연결 시 REST 폴백) */ @@ -18,20 +19,24 @@ export const useUpdateSubTaskAssignee = (taskId: number) => { mutationFn: async ({ taskId: tid, subTaskId, - assigneeId, + assigneeIds, }: UpdateSubTaskAssigneeInput): Promise => { const socket = getSocket(); if (socket?.connected) { const socketOk = await new Promise((resolve) => { socket.emit( SOCKET_SET_ASSIGNEE, - { taskId: tid, subTaskId, assigneeId }, - (res: { success?: boolean; error?: string }) => resolve(!!res?.success), + { taskId: tid, subTaskId, assigneeIds }, + (res: { success?: boolean; error?: string }) => { + // TODO: 소켓 다인원 확인후 콘솔 로그 제거 + console.log('[setSubTaskAssignee] ack', res); + resolve(!!res?.success); + }, ); }); if (socketOk) return; } - await updateSubTaskAssignee(subTaskId, { assigneeId }); + await updateSubTaskAssignee(subTaskId, { assigneeIds }); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['taskDetail', taskId] }); diff --git a/src/features/assignment/team/components/hooks/useUpdateSubTaskDeadline.ts b/src/features/assignment/team/components/hooks/useUpdateSubTaskDeadline.ts index 6f6ab16a..08d2ba40 100644 --- a/src/features/assignment/team/components/hooks/useUpdateSubTaskDeadline.ts +++ b/src/features/assignment/team/components/hooks/useUpdateSubTaskDeadline.ts @@ -7,7 +7,7 @@ import { updateSubTaskDeadline } from '@/services/subtask'; interface UpdateSubTaskDeadlineInput { taskId: number; subTaskId: number; - endDate: string; // YYYY-MM-DD + endDate: string | null; // YYYY-MM-DD } /** 팀 과제 세부 TASK 마감일 변경 (소켓 우선, 미연결 시 REST 폴백) */ diff --git a/src/features/assignment/team/components/hooks/useUpdateSubTasks.ts b/src/features/assignment/team/components/hooks/useUpdateSubTasks.ts index 9d55e892..a60007c5 100644 --- a/src/features/assignment/team/components/hooks/useUpdateSubTasks.ts +++ b/src/features/assignment/team/components/hooks/useUpdateSubTasks.ts @@ -7,7 +7,7 @@ import { updateSubTasksBatch } from '@/services/subtask'; interface SubTaskUpdateItem { subTaskId: number; title?: string; - endDate?: string; + endDate?: string | null; isAlarm?: boolean; } @@ -37,7 +37,7 @@ export const useUpdateSubTasks = (taskId: number) => { subTasks: data.map((item) => ({ subTaskId: item.subTaskId, title: item.title ?? '', - deadline: item.endDate ?? '', + deadline: item.endDate ?? null, isAlarm: item.isAlarm ?? false, })), }); diff --git a/src/features/home/components/Calendar.tsx b/src/features/home/components/Calendar.tsx index dc1f472b..8254df30 100644 --- a/src/features/home/components/Calendar.tsx +++ b/src/features/home/components/Calendar.tsx @@ -5,13 +5,20 @@ import FullCalendar from '@fullcalendar/react'; import type { EventClickArg, EventDropArg } from '@fullcalendar/core'; import dayGridPlugin from '@fullcalendar/daygrid'; import interactionPlugin from '@fullcalendar/interaction'; +import type { EventReceiveArg } from '@fullcalendar/interaction'; import '@/styles/fullcalendar.css'; +import { css } from 'styled-system/css'; +import { stack } from 'styled-system/patterns'; import { token } from 'styled-system/tokens'; import type { FolderColor } from '@/types/folder'; import { useRouter } from 'next/navigation'; import { useCalendarStore } from '@/stores/calendar-store'; import { useUpdateTaskDeadline } from '@/hooks/mutations/useUpdateTaskDeadline'; import { useUpdateSubTaskDeadlineForCalendar } from '@/hooks/mutations/useUpdateSubTaskDeadlineForCalendar'; +import { + UnspecifiedTaskSection, + type UnspecifiedSubTask, +} from './UnspecifiedTaskSection'; // FolderColor → Panda CSS 토큰 매핑 const FOLDER_COLOR_MAP: Record = { @@ -40,7 +47,7 @@ interface SubTask { taskId: number; title: string; status: string; - dueDate: string; + dueDate: string | null; deadlineTime?: string; folderColor: FolderColor; } @@ -93,26 +100,47 @@ export const Calendar = ({ // 세부과제를 캘린더 이벤트로 변환 const filteredTaskIds = new Set(taskEvents.map((e) => Number(e.id))); - const subTaskEvents = subItems - .filter((st) => filteredTaskIds.has(st.taskId)) - .map((st) => { - const parentTask = items.find((a) => a.id === st.taskId); - const type = parentTask?.assignmentType === '팀' ? 'team' : 'personal'; - return { - id: `sub-${st.subTaskId}`, - title: st.title, - start: st.dueDate, - allDay: true, - url: `/assignment/${type}/${st.taskId}`, - backgroundColor: 'transparent', - borderColor: FOLDER_COLOR_MAP[st.folderColor], - textColor: FOLDER_COLOR_MAP[st.folderColor], - classNames: ['fc-subtask-event'], - }; - }); + const unspecifiedSubTasks: UnspecifiedSubTask[] = subItems + .filter((st) => filteredTaskIds.has(st.taskId) && st.dueDate === null) + .map((st) => ({ + subTaskId: st.subTaskId, + taskId: st.taskId, + title: st.title, + folderColor: st.folderColor, + })); + const datedSubItems = subItems.filter( + (st): st is SubTask & { dueDate: string } => + filteredTaskIds.has(st.taskId) && st.dueDate !== null, + ); + const subTaskEvents = datedSubItems.map((st) => { + const parentTask = items.find((a) => a.id === st.taskId); + const type = parentTask?.assignmentType === '팀' ? 'team' : 'personal'; + return { + id: `sub-${st.subTaskId}`, + title: st.title, + start: st.dueDate, + allDay: true, + url: `/assignment/${type}/${st.taskId}`, + backgroundColor: 'transparent', + borderColor: FOLDER_COLOR_MAP[st.folderColor], + textColor: FOLDER_COLOR_MAP[st.folderColor], + classNames: ['fc-subtask-event'], + }; + }); const filteredEvents = [...taskEvents, ...subTaskEvents]; + const canDropSubTaskOnDate = (subTaskId: number, date: string) => { + const sub = subItems.find((s) => s.subTaskId === subTaskId); + if (!sub) return false; + + const parent = items.find((a) => a.id === sub.taskId); + if (!parent) return false; + if (!parent.dueDate) return true; + + return date <= parent.dueDate; + }; + // 스토어 날짜가 변경되면 캘린더 이동 useEffect(() => { const calendarApi = calendarRef.current?.getApi(); @@ -159,23 +187,55 @@ export const Calendar = ({ const childSubs = subItems.filter((s) => s.taskId === taskId); if (childSubs.length === 0) return true; // 세부과제 중 가장 늦은 마감일 이전으로는 이동 불가 - const latestSubDate = childSubs + const childDueDates = childSubs .map((s) => s.dueDate) - .filter(Boolean) - .sort() - .at(-1)!; + .filter((dueDate): dueDate is string => dueDate !== null) + .sort(); + const latestSubDate = childDueDates.at(-1); + if (!latestSubDate) return true; + return dropInfo.startStr >= latestSubDate; } // 세부과제 이동: 상위 과제 마감일 이후로는 이동 불가 const subTaskId = Number(eventId.replace('sub-', '')); - const sub = subItems.find((s) => s.subTaskId === subTaskId); - if (!sub) return false; + return canDropSubTaskOnDate(subTaskId, dropInfo.startStr); + }; - const parent = items.find((a) => a.id === sub.taskId); - if (!parent) return false; + // 날짜 미지정 세부과제를 캘린더로 드롭하면 마감일 지정 + const handleEventReceive = (info: EventReceiveArg) => { + const subTaskId = Number(info.event.extendedProps.subTaskId); + const newDate = info.event.startStr; + + if (!subTaskId || !newDate || !canDropSubTaskOnDate(subTaskId, newDate)) { + info.revert(); + return; + } + + info.event.remove(); - return dropInfo.startStr <= parent.dueDate; + const endDate = `${newDate}T23:59:59`; + setSubItems((prev) => + prev.map((item) => + item.subTaskId === subTaskId + ? { ...item, dueDate: newDate, deadlineTime: '23:59:59' } + : item, + ), + ); + updateSubTaskDeadline.mutate( + { subTaskId, endDate }, + { + onError: () => { + setSubItems((prev) => + prev.map((item) => + item.subTaskId === subTaskId + ? { ...item, dueDate: null, deadlineTime: undefined } + : item, + ), + ); + }, + }, + ); }; // 캘린더에서 이벤트 드래그 시 마감일 변경 (과제 / 세부과제 구분) @@ -218,21 +278,32 @@ export const Calendar = ({ }; return ( - +
+ + +
); }; + +const calendarContentStyle = css( + stack.raw({ + gap: '1rem', + }), +); diff --git a/src/features/home/components/HomeContent.tsx b/src/features/home/components/HomeContent.tsx index 3a4ddf32..2ab11856 100644 --- a/src/features/home/components/HomeContent.tsx +++ b/src/features/home/components/HomeContent.tsx @@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from 'react'; import { css } from 'styled-system/css'; -import { hstack } from 'styled-system/patterns'; +import { hstack, stack } from 'styled-system/patterns'; import { FilterChipGroup } from '@/features/home/components/FilterChipGroup'; import { Calendar } from '@/features/home/components/Calendar'; import { AssignmentSection } from '@/features/home/components/AssignmentSection'; @@ -59,7 +59,7 @@ export const HomeContent = () => { }, [data, selectedFolderIds, allFolderIds, setSelectedFolderIds]); return ( - <> +
{/* 폴더 필터 바 */} { onSortChange={setSortType} />
- +
); }; +const containerStyle = css( + stack.raw({ + gap: '1rem', + }), +); + const mainStyle = css( hstack.raw({ gap: '1.5rem', @@ -100,8 +106,4 @@ const mainStyle = css( const calendarStyle = css({ width: '45.9375rem', minHeight: '44rem', - border: '0.0625rem solid', - borderColor: 'gray.200', - borderRadius: '0.75rem', - boxShadow: '-1px 1px 4px 0 rgba(0, 0, 0, 0.08)', }); diff --git a/src/features/home/components/UnspecifiedTaskSection.tsx b/src/features/home/components/UnspecifiedTaskSection.tsx new file mode 100644 index 00000000..7363e9f8 --- /dev/null +++ b/src/features/home/components/UnspecifiedTaskSection.tsx @@ -0,0 +1,224 @@ +'use client'; + +import { useEffect, useMemo, useRef, useState } from 'react'; +import { Draggable } from '@fullcalendar/interaction'; +import { css, cva } from 'styled-system/css'; +import { hstack, stack } from 'styled-system/patterns'; +import { ChevronLineDownIcon } from '@/components/icons/ChevronLineDownIcon'; +import { DragIcon } from '@/components/icons/DragIcon'; +import type { FolderColor } from '@/types/folder'; + +export type UnspecifiedSubTask = { + subTaskId: number; + taskId: number; + title: string; + folderColor: FolderColor; +}; + +type UnspecifiedTaskSectionProps = { + tasks: UnspecifiedSubTask[]; +}; + +const MAX_COLLAPSED_COUNT = 5; + +export const UnspecifiedTaskSection = ({ + tasks, +}: UnspecifiedTaskSectionProps) => { + const taskWrapRef = useRef(null); + const [expanded, setExpanded] = useState(false); + const canExpand = tasks.length > MAX_COLLAPSED_COUNT; + const visibleTasks = useMemo( + () => + canExpand && !expanded ? tasks.slice(0, MAX_COLLAPSED_COUNT) : tasks, + [canExpand, expanded, tasks], + ); + + useEffect(() => { + const taskWrap = taskWrapRef.current; + if (!taskWrap) return; + + const draggable = new Draggable(taskWrap, { + itemSelector: '.unspecified-task-draggable', + eventData: (eventEl) => ({ + id: `sub-${eventEl.dataset.subTaskId}`, + title: eventEl.dataset.title ?? '', + allDay: true, + backgroundColor: 'transparent', + classNames: ['fc-subtask-event'], + extendedProps: { + subTaskId: Number(eventEl.dataset.subTaskId), + taskId: Number(eventEl.dataset.taskId), + folderColor: eventEl.dataset.folderColor, + }, + }), + }); + + return () => draggable.destroy(); + }, [visibleTasks]); + + if (tasks.length === 0) return null; + + return ( +
+
+

날짜 미지정 TASK

+ {canExpand && ( + + )} +
+ +
+ {visibleTasks.map((task) => ( + + ))} +
+
+ ); +}; + +const sectionStyle = css( + stack.raw({ + gap: '0.5rem', + width: '100%', + }), +); + +const headerStyle = css( + hstack.raw({ + justifyContent: 'space-between', + alignItems: 'center', + minHeight: '1.5rem', + }), +); + +const titleStyle = css({ + textStyle: 'body2.r', + color: 'gray.800', +}); + +const expandButtonStyle = css( + hstack.raw({ + gap: '0.125rem', + alignItems: 'center', + color: 'gray.600', + textStyle: 'body4.r', + bg: 'transparent', + border: 'none', + cursor: 'pointer', + p: 0, + '& svg': { + width: '1rem', + height: '1rem', + stroke: 'gray.600', + }, + }), +); + +const expandIconStyle = cva({ + base: { + display: 'inline-flex', + }, + variants: { + expanded: { + true: { transform: 'rotate(180deg)' }, + false: { transform: 'rotate(0deg)' }, + }, + }, +}); + +const taskWrapStyle = css({ + display: 'flex', + flexWrap: 'wrap', + gap: '0.25rem', +}); + +const taskItemStyle = css( + hstack.raw({ + gap: 0, + alignItems: 'center', + bg: 'transparent', + border: 'none', + p: 0, + cursor: 'grab', + _active: { + cursor: 'grabbing', + }, + }), +); + +const dragIconStyle = css({ + width: '0.875rem', + height: '0.875rem', + flexShrink: 0, +}); + +const taskChipStyle = cva({ + base: { + maxWidth: '8.125rem', + height: '1.1875rem', + px: '0.25rem', + py: '0.125rem', + borderWidth: '0.0625rem', + borderStyle: 'solid', + borderRadius: '0.125rem', + textStyle: 'caption', + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + }, + variants: { + color: { + red: { + color: 'sub.01.100', + borderColor: 'sub.01.100', + }, + yellow: { + color: 'sub.02.100', + borderColor: 'sub.02.100', + }, + green: { + color: 'sub.03.100', + borderColor: 'sub.03.100', + }, + purple: { + color: 'sub.04.100', + borderColor: 'sub.04.100', + }, + black: { + color: 'sub.05.100', + borderColor: 'sub.05.100', + }, + null: { + color: 'sub.null.100', + borderColor: 'sub.null.100', + }, + }, + }, +}); diff --git a/src/features/login/components/AppleLoginButton.tsx b/src/features/login/components/AppleLoginButton.tsx new file mode 100644 index 00000000..30d0ee13 --- /dev/null +++ b/src/features/login/components/AppleLoginButton.tsx @@ -0,0 +1,37 @@ +'use client'; + +import Image from 'next/image'; +import { css } from 'styled-system/css'; +import { hstack } from 'styled-system/patterns'; + +export const AppleLoginButton = () => { + const handleClick = () => { + const isLocal = process.env.NODE_ENV === 'development'; + const state = isLocal ? 'local' : 'prod'; + window.location.href = `${process.env.NEXT_PUBLIC_API_URL}/auth/apple?state=${state}`; + }; + + return ( + + ); +}; + +const buttonStyle = css( + hstack.raw({ + justifyContent: 'center', + gap: '0.25rem', + width: '24.125rem', + height: '3.375rem', + bg: 'bg', + borderRadius: '0.5rem', + cursor: 'pointer', + }), +); + +const buttonTextStyle = css({ + textStyle: 'btn', + color: 'rgba(0, 0, 0, 0.85)', +}); diff --git a/src/features/login/components/KakaoAgreementModalTrigger.tsx b/src/features/login/components/KakaoAgreementModalTrigger.tsx index 75732445..ad3e961b 100644 --- a/src/features/login/components/KakaoAgreementModalTrigger.tsx +++ b/src/features/login/components/KakaoAgreementModalTrigger.tsx @@ -2,7 +2,7 @@ import { useEffect, useRef } from 'react'; import { KakaoAgreementModalContent } from '@/features/login/components/KakaoAgreementModalContent'; -import { KAKAO_AGREEMENT_REQUIRED_KEY } from '@/features/login/constants/kakaoAgreement'; +import { SOCIAL_AGREEMENT_REQUIRED_KEY } from '@/features/login/constants/socialAgreement'; import { useModalStore } from '@/stores/modal-store'; export const KakaoAgreementModalTrigger = () => { @@ -15,7 +15,7 @@ export const KakaoAgreementModalTrigger = () => { if (typeof window === 'undefined') return; const shouldOpen = - window.sessionStorage.getItem(KAKAO_AGREEMENT_REQUIRED_KEY) === 'true'; + window.sessionStorage.getItem(SOCIAL_AGREEMENT_REQUIRED_KEY) === 'true'; if (!shouldOpen) return; @@ -25,7 +25,7 @@ export const KakaoAgreementModalTrigger = () => { content: ( { - window.sessionStorage.removeItem(KAKAO_AGREEMENT_REQUIRED_KEY); + window.sessionStorage.removeItem(SOCIAL_AGREEMENT_REQUIRED_KEY); closeModal(); }} /> diff --git a/src/features/login/components/KakaoLoginButton.tsx b/src/features/login/components/KakaoLoginButton.tsx index e3360261..0359d486 100644 --- a/src/features/login/components/KakaoLoginButton.tsx +++ b/src/features/login/components/KakaoLoginButton.tsx @@ -12,8 +12,8 @@ export const KakaoLoginButton = () => { }; return ( - ); diff --git a/src/features/login/components/LoginModalContent.tsx b/src/features/login/components/LoginModalContent.tsx index adef8649..61fba264 100644 --- a/src/features/login/components/LoginModalContent.tsx +++ b/src/features/login/components/LoginModalContent.tsx @@ -9,6 +9,7 @@ import { css, cx } from 'styled-system/css'; import { hstack, stack } from 'styled-system/patterns'; import { Button } from '@/components/Button'; import { Input } from '@/components/TextField'; +import { AppleLoginButton } from '@/features/login/components/AppleLoginButton'; import { KakaoLoginButton } from '@/features/login/components/KakaoLoginButton'; import { EyeIcon } from '@/components/icons/EyeIcon'; import { EyeOffIcon } from '@/components/icons/EyeOffIcon'; @@ -235,7 +236,10 @@ export const LoginModalContent = () => {
- +
+ + +

로그인 시{' '} @@ -400,6 +404,12 @@ const orTextStyle = css({ color: 'gray.400', }); +const socialLoginGroupStyle = css( + stack.raw({ + gap: '1rem', + }), +); + const consentStyle = css({ mt: '2rem', textStyle: 'body4.r', diff --git a/src/features/login/constants/kakaoAgreement.ts b/src/features/login/constants/kakaoAgreement.ts deleted file mode 100644 index 6a7ac327..00000000 --- a/src/features/login/constants/kakaoAgreement.ts +++ /dev/null @@ -1 +0,0 @@ -export const KAKAO_AGREEMENT_REQUIRED_KEY = 'kakaoAgreementRequired'; diff --git a/src/features/login/constants/socialAgreement.ts b/src/features/login/constants/socialAgreement.ts new file mode 100644 index 00000000..2692f822 --- /dev/null +++ b/src/features/login/constants/socialAgreement.ts @@ -0,0 +1 @@ +export const SOCIAL_AGREEMENT_REQUIRED_KEY = 'socialAgreementRequired'; diff --git a/src/features/profile/components/ManagementSection.tsx b/src/features/profile/components/ManagementSection.tsx index 52f917c4..7c7ea67c 100644 --- a/src/features/profile/components/ManagementSection.tsx +++ b/src/features/profile/components/ManagementSection.tsx @@ -19,7 +19,8 @@ import { } from '@dnd-kit/sortable'; import { restrictToParentElement } from '@dnd-kit/modifiers'; import { CSS } from '@dnd-kit/utilities'; -import { css, cva } from 'styled-system/css'; +import { useRouter } from 'next/navigation'; +import { css, cva, cx } from 'styled-system/css'; import { hstack, stack } from 'styled-system/patterns'; import { Card } from '@/features/profile/components/Card'; import { NotificationSetting } from '@/features/profile/components/NotificationSetting'; @@ -71,6 +72,7 @@ const SortableFolderRow = ({ folder }: { folder: Folder }) => { }; export const ManagementSection = () => { + const router = useRouter(); const [isReorder, setIsReorder] = useState(false); const [orderedFolders, setOrderedFolders] = useState([]); const [snapshotFolders, setSnapshotFolders] = useState([]); @@ -127,6 +129,11 @@ export const ManagementSection = () => { (folder) => folder.name === UNASSIGNED_FOLDER_NAME, ); + // 폴더 클릭 시 진행중 개인 과제 목록 페이지로 이동, 해당 폴더로 필터링 + const handleFolderClick = (folderId: number) => { + router.push(`/assignment?type=personal&folderId=${folderId}`); + }; + // 저장: 지정안함 폴더를 항상 1순위로 고정하고, 그 아래 순서만 서버에 반영 const handleSaveReorder = () => { const payload = [...pinnedFolders, ...reorderableFolders].map( @@ -242,12 +249,16 @@ export const ManagementSection = () => {

{orderedFolders.map((folder) => (
-
+ {folder.name !== UNASSIGNED_FOLDER_NAME && (
{ const queryClient = useQueryClient(); @@ -23,7 +26,12 @@ export const useCreateSubTask = (taskId: number) => { } return createSubTask(taskId, body); }, - onMutate: (body: CreateSubTaskRequest) => { + onMutate: async (body: CreateSubTaskRequest) => { + await queryClient.cancelQueries({ queryKey: ['taskDetail', taskId] }); + + // 롤백 시 다른 낙관적 항목까지 지우지 않도록, 이 mutation이 추가한 임시 id만 기억해둠 + const tempId = nextTempSubTaskId--; + // 임시 ID로 즉시 캐시에 추가 → 폼 닫힘과 동시에 목록에 노출 queryClient.setQueryData(['taskDetail', taskId], (old) => { if (!old) return old; @@ -32,21 +40,34 @@ export const useCreateSubTask = (taskId: number) => { subTasks: [ ...old.subTasks, { - subTaskId: Date.now(), + subTaskId: tempId, title: body.title, deadline: body.deadline, status: 'PROGRESS' as const, isAlarm: body.isAlarm, commentCount: 0, - assigneeName: '', + assignees: [], }, ], }; }); + + return { tempId }; }, onSuccess: () => { // 백그라운드에서 서버와 동기화 (임시 ID → 실제 ID) queryClient.invalidateQueries({ queryKey: ['taskDetail', taskId] }); }, + onError: (_error, _variables, context) => { + // 생성 실패 시 이 mutation이 추가했던 임시 항목만 제거 (다른 진행 중인 낙관적 항목은 유지) + if (context?.tempId == null) return; + queryClient.setQueryData(['taskDetail', taskId], (old) => { + if (!old) return old; + return { + ...old, + subTasks: old.subTasks.filter((t) => t.subTaskId !== context.tempId), + }; + }); + }, }); }; diff --git a/src/hooks/mutations/useUpdateSubTaskDeadlineForCalendar.ts b/src/hooks/mutations/useUpdateSubTaskDeadlineForCalendar.ts index d3c7623c..a5e1a702 100644 --- a/src/hooks/mutations/useUpdateSubTaskDeadlineForCalendar.ts +++ b/src/hooks/mutations/useUpdateSubTaskDeadlineForCalendar.ts @@ -1,31 +1,85 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import { updateSubTaskDeadline } from '@/services/subtask'; +import type { + HomeAssignment, + HomeSubTask, +} from '@/hooks/queries/useHomeTaskList'; + +type HomeTaskListCache = { + assignments: HomeAssignment[]; + subTasks: HomeSubTask[]; +}; + +const isHomeTaskListQueryKey = (queryKey: readonly unknown[]) => + queryKey[0] === 'taskList' && + typeof queryKey[1] === 'object' && + queryKey[1] !== null && + 'sort' in queryKey[1]; + +const isHomeTaskListCache = (data: unknown): data is HomeTaskListCache => + typeof data === 'object' && + data !== null && + Array.isArray((data as HomeTaskListCache).assignments) && + Array.isArray((data as HomeTaskListCache).subTasks); // 캘린더에서 세부과제 드래그 시 마감일 변경 훅 (taskList 무효화) export const useUpdateSubTaskDeadlineForCalendar = () => { const queryClient = useQueryClient(); return useMutation({ - mutationFn: ({ subTaskId, endDate }: { subTaskId: number; endDate: string }) => - updateSubTaskDeadline(subTaskId, { endDate }), + mutationFn: ({ + subTaskId, + endDate, + }: { + subTaskId: number; + endDate: string | null; + }) => updateSubTaskDeadline(subTaskId, { endDate }), onMutate: async ({ subTaskId, endDate }) => { - await queryClient.cancelQueries({ queryKey: ['taskList'] }); - // 모든 sort 변형의 캐시를 즉시 업데이트 - queryClient.setQueriesData<{ - assignments: unknown[]; - subTasks: { subTaskId: number; dueDate: string }[]; - }>({ queryKey: ['taskList'] }, (old) => { - if (!old) return old; - return { - ...old, - subTasks: old.subTasks.map((st) => - st.subTaskId === subTaskId ? { ...st, dueDate: endDate } : st, - ), - }; + const dueDate = endDate ? endDate.split('T')[0] : null; + const deadlineTime = endDate?.includes('T') + ? endDate.split('T')[1] + : undefined; + + await queryClient.cancelQueries({ + predicate: (query) => isHomeTaskListQueryKey(query.queryKey), }); + + const previousHomeTaskLists = + queryClient.getQueriesData({ + predicate: (query) => isHomeTaskListQueryKey(query.queryKey), + }); + + queryClient.setQueriesData( + { predicate: (query) => isHomeTaskListQueryKey(query.queryKey) }, + (old) => { + if (!isHomeTaskListCache(old)) return old; + + return { + ...old, + subTasks: old.subTasks.map((st) => + st.subTaskId === subTaskId + ? { ...st, dueDate, deadlineTime } + : st, + ), + }; + }, + ); + + return { previousHomeTaskLists }; }, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['taskList'] }); + queryClient.invalidateQueries({ + predicate: (query) => isHomeTaskListQueryKey(query.queryKey), + }); + }, + onError: (_error, _variables, context) => { + context?.previousHomeTaskLists.forEach(([queryKey, data]) => { + queryClient.setQueryData(queryKey, data); + }); + + queryClient.invalidateQueries({ + predicate: (query) => isHomeTaskListQueryKey(query.queryKey), + }); }, }); }; diff --git a/src/hooks/queries/useHomeTaskList.ts b/src/hooks/queries/useHomeTaskList.ts index 2759bdd7..f44ad158 100644 --- a/src/hooks/queries/useHomeTaskList.ts +++ b/src/hooks/queries/useHomeTaskList.ts @@ -25,7 +25,7 @@ export interface HomeSubTask { taskId: number; title: string; status: string; - dueDate: string; + dueDate: string | null; deadlineTime?: string; folderColor: FolderColor; } @@ -70,7 +70,7 @@ export const useHomeTaskList = (sort?: TaskSort) => { taskId: st.taskId, title: st.title, status: st.status, - dueDate: st.deadline ? st.deadline.split('T')[0] : '', + dueDate: st.deadline ? st.deadline.split('T')[0] : null, deadlineTime: st.deadline?.includes('T') ? st.deadline.split('T')[1] : undefined, diff --git a/src/hooks/queries/useTaskMemberProfile.ts b/src/hooks/queries/useTaskMemberProfile.ts new file mode 100644 index 00000000..06fa724b --- /dev/null +++ b/src/hooks/queries/useTaskMemberProfile.ts @@ -0,0 +1,10 @@ +import { useQuery } from '@tanstack/react-query'; +import { getTaskMemberProfile } from '@/services/task'; + +export const useTaskMemberProfile = (taskId: number, userId?: number) => { + return useQuery({ + queryKey: ['taskMemberProfile', taskId, userId], + queryFn: () => getTaskMemberProfile(taskId, userId as number), + enabled: taskId > 0 && userId != null, + }); +}; diff --git a/src/lib/socket.ts b/src/lib/socket.ts index 30b09af4..b3b1bca3 100644 --- a/src/lib/socket.ts +++ b/src/lib/socket.ts @@ -144,7 +144,7 @@ export const TASK_UPDATED_EVENT = 'task:updated'; export const SOCKET_UPDATE_SUBTASK = 'updateSubtaskStatus'; /** 클라이언트 → 서버: 세부과제 마감일 (백엔드 taskEvents.UPDATE_DEADLINE) payload: { taskId, subTaskId, endDate } */ export const SOCKET_UPDATE_DEADLINE = 'updateDeadline'; -/** 클라이언트 → 서버: 세부과제 담당자 (백엔드 taskEvents.SET_ASSIGNEE) payload: { taskId, subTaskId, assigneeId } */ +/** 클라이언트 → 서버: 세부과제 담당자 (백엔드 taskEvents.SET_ASSIGNEE) payload: { taskId, subTaskId, assigneeIds } */ export const SOCKET_SET_ASSIGNEE = 'setSubTaskAssignee'; /** 클라이언트 → 서버: 단일 세부과제 생성 (백엔드 taskEvents.CREATE_SUBTASK) payload: { taskId, title?, deadline?, isAlarm? } */ export const SOCKET_CREATE_SUBTASK = 'subtask:create'; diff --git a/src/services/task.ts b/src/services/task.ts index f70dafa4..ea3150a2 100644 --- a/src/services/task.ts +++ b/src/services/task.ts @@ -20,6 +20,8 @@ import { UpdateTaskRequest, PatchTaskRequest, JoinTaskResult, + TaskMemberProfile, + GetTaskMemberProfileResponse, } from '@/types/task'; import axiosInstance from '@/lib/axiosInstance'; @@ -98,30 +100,15 @@ export const getTaskDetail = async (taskId: number): Promise => { ); } - // subTasks: API는 camelCase(assigneeId 등), assigneeId가 null일 수 있음 - const rawSubTasks = - data.subTasks ?? (data as { sub_tasks?: unknown[] }).sub_tasks; + // subTasks: API는 camelCase(assignees 등) + const rawSubTasks = data.subTasks; if (rawSubTasks?.length) { data.subTasks = rawSubTasks.map( - ( - st: TaskDetailSubTask & { - sub_task_id?: number; - assignee_id?: number | null; - assignee_name?: string; - assignee_profile_image?: string | null; - }, - ) => { - const rawAssigneeId = st.assigneeId ?? st.assignee_id; - return { - ...st, - subTaskId: st.subTaskId ?? st.sub_task_id ?? 0, - assigneeId: rawAssigneeId != null ? rawAssigneeId : undefined, - assigneeName: - st.assigneeName ?? st.assignee_name ?? st.assigneeName ?? '', - assigneeProfileImage: - st.assigneeProfileImage ?? st.assignee_profile_image ?? undefined, - }; - }, + (st: TaskDetailSubTask & { sub_task_id?: number }) => ({ + ...st, + subTaskId: st.subTaskId ?? st.sub_task_id ?? 0, + assignees: st.assignees ?? [], + }), ); } @@ -350,6 +337,17 @@ export interface GetTaskMembersResponse { }; } +// 팀과제 특정 팀원 프로필 조회 api 호출 (GET /task/{taskId}/members/{userId}) +export const getTaskMemberProfile = async ( + taskId: number, + userId: number, +): Promise => { + const res = await axiosInstance.get( + `${TASK_BASE}/${taskId}/members/${userId}`, + ); + return res.data.data; +}; + // 팀원 역할 수정 (PATCH /task/{taskId}/member/{userId}) export const updateMemberRole = async ( taskId: number, diff --git a/src/styles/fullcalendar.css b/src/styles/fullcalendar.css index 05b3c467..08afc8e5 100644 --- a/src/styles/fullcalendar.css +++ b/src/styles/fullcalendar.css @@ -1,6 +1,8 @@ /* 캘린더 전체 컨테이너 */ .fc { + border: 0.0625rem solid var(--colors-gray-200); border-radius: 0.75rem; + box-shadow: -1px 1px 4px 0 rgba(0, 0, 0, 0.08); overflow: hidden; width: 100%; } diff --git a/src/types/task.ts b/src/types/task.ts index ec91737e..543c4842 100644 --- a/src/types/task.ts +++ b/src/types/task.ts @@ -40,7 +40,7 @@ export interface SubTaskListItem { taskId: number; title: string; status: string; - deadline: string; + deadline: string | null; } // 과제 목록 조회 응답 @@ -69,19 +69,24 @@ export interface TaskDetailSubTaskComment { createdAt: string; } -// Task 목록에 보여지는 세부 과제 항목 +// 세부 TASK 각 담당자 (상세 조회 응답) +export interface TaskDetailSubTaskAssignee { + memberId: number; + userId: number; + nickname: string; + profileImage: string | null; +} + +// subTasks 목록에 보여지는 항목 export interface TaskDetailSubTask { subTaskId: number; title: string; - deadline: string; // YYYY-MM-DD + deadline: string | null; // YYYY-MM-DD status: SubTaskStatus; isAlarm: boolean; commentCount: number; comments?: TaskDetailSubTaskComment[]; - /** 담당자 사용자 ID (과제 수정 API용) */ - assigneeId?: number | null; - assigneeName: string; - assigneeProfileImage?: string | null; + assignees: TaskDetailSubTaskAssignee[]; } // 자료 모음집 참조 항목 (과제 상세·자료 생성 응답) @@ -183,7 +188,7 @@ export interface DeleteTaskResponse { // ============================ export interface CreateTaskSubTaskRequest { title: string; - endDate: string; // YYYY-MM-DD + endDate: string | null; // YYYY-MM-DD } export interface CreateTaskReferenceRequest { @@ -209,7 +214,7 @@ export interface CreateTaskResponse { // 과제 수정 요청 (PATCH /task/{taskId}) export interface UpdateTaskSubTaskItem { title: string; - endDate: string; // YYYY-MM-DD + endDate: string | null; // YYYY-MM-DD status: 'PROGRESS' | 'COMPLETED'; isAlarm: boolean; assigneeId: number; @@ -259,7 +264,7 @@ export interface GetCompletedTaskListResponse { // ============================ export interface UpdateSubTaskDeadlineRequest { - endDate: string; // YYYY-MM-DD + endDate: string | null; // YYYY-MM-DD } export interface UpdateSubTaskDeadlineResponse { @@ -267,7 +272,7 @@ export interface UpdateSubTaskDeadlineResponse { message: string; data: { sub_task_id: number; - end_date: string; // YYYY-MM-DD + end_date: string | null; // YYYY-MM-DD }; } @@ -348,7 +353,7 @@ export interface UpdateSubTaskStatusResponse { // 세부 TASK 담당자 설정 타입 정의 // ============================ export interface UpdateSubTaskAssigneeRequest { - assigneeId: number | null; + assigneeIds: number[]; } export interface UpdateSubTaskAssigneeResponse { @@ -356,7 +361,7 @@ export interface UpdateSubTaskAssigneeResponse { message: string; data: { sub_task_id: number; - assignee_id: number; + assignees: TaskDetailSubTaskAssignee[]; }; } @@ -365,7 +370,7 @@ export interface UpdateSubTaskAssigneeResponse { // ============================ export interface CreateSubTaskRequest { title: string; - deadline: string; // YYYY-MM-DDTHH:mm:ss + deadline: string | null; // YYYY-MM-DDTHH:mm:ss isAlarm: boolean; } @@ -375,7 +380,7 @@ export interface CreateSubTaskResponse { data: { subTaskId: number; title: string; - deadline: string; + deadline: string | null; status: string; assigneeName: string; }; @@ -450,10 +455,26 @@ export interface DeleteSubTasksBulkResponse { export interface UpdateSubTasksBatchItem { subTaskId: number; title: string; - deadline: string; + deadline: string | null; isAlarm: boolean; } export interface UpdateSubTasksBatchRequest { subTasks: UpdateSubTasksBatchItem[]; } + +// ============================ +// 팀과제 특정 팀원 프로필 조회 타입 정의 +// ============================ +export interface TaskMemberProfile { + nickname: string; + phoneNum?: string | null; + email?: string | null; + profileImage?: string | null; +} + +export interface GetTaskMemberProfileResponse { + resultType: 'SUCCESS' | 'FAIL'; + message: string; + data: TaskMemberProfile; +}