From 060f133857ce2fa10e9bb379eeb9dcef06add1d7 Mon Sep 17 00:00:00 2001 From: strdeok Date: Thu, 30 Oct 2025 14:43:59 +0900 Subject: [PATCH 1/3] feat: Optimistic UI for application status updates --- .../edit/[id]/_components/EditApplication.tsx | 3 +- src/app/(afterLogin)/applications/page.tsx | 36 +++++----- src/hooks/useApplications.ts | 70 ++++++++++++++++++- 3 files changed, 86 insertions(+), 23 deletions(-) diff --git a/src/app/(afterLogin)/applications/edit/[id]/_components/EditApplication.tsx b/src/app/(afterLogin)/applications/edit/[id]/_components/EditApplication.tsx index 841cd63..b5a2a96 100644 --- a/src/app/(afterLogin)/applications/edit/[id]/_components/EditApplication.tsx +++ b/src/app/(afterLogin)/applications/edit/[id]/_components/EditApplication.tsx @@ -13,6 +13,7 @@ import { } from "@/hooks/useApplications"; import { ApplicationStatus, + CompanyApplication, CompanyApplicationWithId, ScheduleStatus, typeLabels, @@ -147,7 +148,7 @@ export default function EditApplicationsPage({ id }: { id: number }) { try { await mutateAsync({ - changedApplication: changedApplication, + changedApplication: changedApplication as CompanyApplication, applicationId: id, }); diff --git a/src/app/(afterLogin)/applications/page.tsx b/src/app/(afterLogin)/applications/page.tsx index 7116be2..f16dbb0 100644 --- a/src/app/(afterLogin)/applications/page.tsx +++ b/src/app/(afterLogin)/applications/page.tsx @@ -12,7 +12,7 @@ import Divider from "../documents/_components/divider"; import LoadingSpinner from "@/app/_components/loadingSpinner"; import SearchIcon from "@/assets/Search.svg"; import PlusIcon from "@/assets/Plus.svg"; -import { CompanyApplicationWithId, Schedule } from "@/type/applicationType"; +import { ApplicationStatus, CompanyApplicationWithId, Schedule } from "@/type/applicationType"; import PencilSimpleIcon from "@/assets/PencilSimple.svg"; import TrashSimpleIcon from "@/assets/TrashSimple.svg"; @@ -95,7 +95,7 @@ export default function ApplicationsPage() { alert("선택된 항목이 모두 삭제되었습니다."); setSelectedIds([]); refetch(); - } catch (error) { + } catch (_error) { alert("일부 항목 삭제에 실패했습니다. 페이지를 새로고침합니다."); refetch(); } @@ -115,26 +115,24 @@ export default function ApplicationsPage() { const handleStatusChange = ( app: CompanyApplicationWithId, - status: string + newStatus: string ) => { - const changedApp = { ...app, status }; - updateMutate( - { applicationId: app.id, changedApplication: changedApp }, - { - onError: () => { - alert("오류가 발생하였습니다. 잠시 후 시도해주세요."); - }, - } - ); + const queryKey = ["applications", page, searchQuery]; + updateMutate({ + applicationId: app.id, + changedApplication: { ...app, status: newStatus as ApplicationStatus }, + queryKey: queryKey, + newStatus: newStatus, + }); }; - const formatUrl = (url?: string) => { - if (!url) return ""; - if (url.startsWith("http://") || url.startsWith("https://")) { - return url; - } - return `https://${url}`; - }; + // const formatUrl = (url?: string) => { + // if (!url) return ""; + // if (url.startsWith("http://") || url.startsWith("https://")) { + // return url; + // } + // return `https://${url}`; + // }; return ( <> diff --git a/src/hooks/useApplications.ts b/src/hooks/useApplications.ts index 59493b5..cb79c89 100644 --- a/src/hooks/useApplications.ts +++ b/src/hooks/useApplications.ts @@ -7,6 +7,7 @@ import { updateApplication, uploadApplications, } from "@/lib/applications"; +import { ApplicationStatus, CompanyApplication, CompanyApplicationWithId } from "@/type/applicationType"; // 업로드 export const useUploadApplications = () => { @@ -44,14 +45,77 @@ export const useFetchApplication = (applicationId: number) => { }); }; -// 상태 업데이트 + +type UpdateApplicationVariables = { + applicationId: number; + changedApplication: CompanyApplication; + queryKey?: (string | number)[]; + newStatus?: string; +}; + +// 상태 업데이트 훅 export const useUpdateApplication = () => { const queryClient = useQueryClient(); return useMutation({ - mutationFn: updateApplication, - onSuccess: () => { + mutationFn: (variables: UpdateApplicationVariables) => { + const { applicationId, changedApplication } = variables; + return updateApplication({ applicationId, changedApplication }); + }, + + onMutate: async (variables: UpdateApplicationVariables) => { + const { applicationId, queryKey, newStatus } = variables; + + const context: { + previousListData?: unknown; + queryKey?: (string | number)[]; + previousSingleData?: unknown; + singleAppQueryKey: (string | number)[]; + } = { + singleAppQueryKey: ["application", applicationId] + }; + + await queryClient.cancelQueries({ queryKey: context.singleAppQueryKey }); + context.previousSingleData = queryClient.getQueryData(context.singleAppQueryKey); + queryClient.setQueryData(context.singleAppQueryKey, (oldData: any) => { + if (!oldData) return oldData; + return { + ...oldData, + data: { ...oldData.data, data: variables.changedApplication } + }; + }); + + if (queryKey && newStatus) { + await queryClient.cancelQueries({ queryKey }); + context.previousListData = queryClient.getQueryData(queryKey); + context.queryKey = queryKey; + + queryClient.setQueryData(queryKey, (oldData: any) => { + if (!oldData) return oldData; + const updatedContent = oldData.data.content.map( + (item: CompanyApplicationWithId) => + item.id === applicationId ? { ...item, status: newStatus } : item + ); + return { ...oldData, data: { ...oldData.data, content: updatedContent }}; + }); + } + + return context; + }, + + onError: (err, variables, context: any) => { + alert("오류가 발생하였습니다."); + if (context?.previousSingleData) { + queryClient.setQueryData(context.singleAppQueryKey, context.previousSingleData); + } + if (context?.previousListData && context?.queryKey) { + queryClient.setQueryData(context.queryKey, context.previousListData); + } + }, + + onSettled: (data, error, variables, context: any) => { queryClient.invalidateQueries({ queryKey: ["applications"] }); + queryClient.invalidateQueries({ queryKey: ["application", variables.applicationId] }); queryClient.invalidateQueries({ queryKey: ["dashboard"] }); queryClient.invalidateQueries({ queryKey: ["schedule"] }); }, From fa3b38955a58d56bd3a26b0dab805ad024a3188e Mon Sep 17 00:00:00 2001 From: strdeok Date: Thu, 30 Oct 2025 15:11:31 +0900 Subject: [PATCH 2/3] fix: build error fix --- .../_components/protectedPage.tsx | 2 +- src/app/(afterLogin)/applications/page.tsx | 2 +- .../_components/applicationStatusSection.tsx | 80 ------------------- .../dashboard/_components/scheduleSection.tsx | 80 ------------------- .../_components/documentDescription.tsx | 6 +- .../documents/_components/mainDocuments.tsx | 4 +- 6 files changed, 5 insertions(+), 169 deletions(-) delete mode 100644 src/app/(afterLogin)/dashboard/_components/applicationStatusSection.tsx delete mode 100644 src/app/(afterLogin)/dashboard/_components/scheduleSection.tsx diff --git a/src/app/(afterLogin)/_components/protectedPage.tsx b/src/app/(afterLogin)/_components/protectedPage.tsx index 0788129..28a6fe6 100644 --- a/src/app/(afterLogin)/_components/protectedPage.tsx +++ b/src/app/(afterLogin)/_components/protectedPage.tsx @@ -64,7 +64,7 @@ export default function ProtectedPage({ children }: Props) { }; initializeAuth(); - }, []); + }, [isInitialized, router, searchParams, setInitialized, token, checkAuth]); if (!isInitialized) return null; diff --git a/src/app/(afterLogin)/applications/page.tsx b/src/app/(afterLogin)/applications/page.tsx index f16dbb0..da3d072 100644 --- a/src/app/(afterLogin)/applications/page.tsx +++ b/src/app/(afterLogin)/applications/page.tsx @@ -95,7 +95,7 @@ export default function ApplicationsPage() { alert("선택된 항목이 모두 삭제되었습니다."); setSelectedIds([]); refetch(); - } catch (_error) { + } catch { alert("일부 항목 삭제에 실패했습니다. 페이지를 새로고침합니다."); refetch(); } diff --git a/src/app/(afterLogin)/dashboard/_components/applicationStatusSection.tsx b/src/app/(afterLogin)/dashboard/_components/applicationStatusSection.tsx deleted file mode 100644 index 6adce30..0000000 --- a/src/app/(afterLogin)/dashboard/_components/applicationStatusSection.tsx +++ /dev/null @@ -1,80 +0,0 @@ -"use client"; - -import { useFetchAllApplications } from "@/hooks/useApplications"; -import { useMemo } from "react"; -import { CompanyApplication } from "@/type/applicationType"; - -export default function ApplicationStatusSection() { - const { data: ApplicationsData, isLoading } = useFetchAllApplications(0, ""); - - const applicationStatus = useMemo(() => { - if (!ApplicationsData?.data.content) { - return { APPLIED: 0, DOCUMENT_PASSED: 0, FINAL_PASSED: 0, REJECTED: 0 }; - } - - const applications = ApplicationsData.data - .content as Array; - - return applications.reduce( - (counts, app) => { - if (counts.hasOwnProperty(app.status)) { - counts[app.status as keyof typeof counts]++; - } - return counts; - }, - { APPLIED: 0, DOCUMENT_PASSED: 0, FINAL_PASSED: 0, REJECTED: 0 } - ); - }, [ApplicationsData]); - - const box_style = "w-60 border rounded-sm px-9 py-5 flex flex-col gap-4"; - return ( -
-
- 지원 회사 - {applicationStatus.APPLIED}개 -
-
- 서류 합격 - - {applicationStatus.DOCUMENT_PASSED}개 - -
-
- 불합격 - - {applicationStatus.REJECTED}개 - -
-
- 최종 합격 - - {applicationStatus.FINAL_PASSED}개 - -
-
- ); -} diff --git a/src/app/(afterLogin)/dashboard/_components/scheduleSection.tsx b/src/app/(afterLogin)/dashboard/_components/scheduleSection.tsx deleted file mode 100644 index 6c02c48..0000000 --- a/src/app/(afterLogin)/dashboard/_components/scheduleSection.tsx +++ /dev/null @@ -1,80 +0,0 @@ -"use client"; - -import { useSchedule } from "@/hooks/useSchedule"; -import { Schedule } from "@/type/applicationType"; -import CalendarIcon from "@/assets/CalendarCheck.svg"; -import { useMemo } from "react"; - -function formatDateToApiString(date: Date) { - const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, "0"); - const day = String(date.getDate()).padStart(2, "0"); - const hours = String(date.getHours()).padStart(2, "0"); - const minutes = String(date.getMinutes()).padStart(2, "0"); - const seconds = String(date.getSeconds()).padStart(2, "0"); - return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}`; -} - -function getDDay(dateTime: string) { - const today = new Date(); - const targetDate = new Date(dateTime); - - const todayOnly = new Date( - today.getFullYear(), - today.getMonth(), - today.getDate() - ); - const targetOnly = new Date( - targetDate.getFullYear(), - targetDate.getMonth(), - targetDate.getDate() - ); - - const diffTime = targetOnly.getTime() - todayOnly.getTime(); - const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); - - if (diffDays > 0) return `D-${diffDays}`; - else if (diffDays === 0) return `D-Day`; - else return `D+${Math.abs(diffDays)}`; -} - -export default function ScheduleSection() { - const { startDate, endDate } = useMemo(() => { - const now = new Date(); - const futureDate = new Date(now); - futureDate.setDate(now.getDate() + 7); - - return { - startDate: formatDateToApiString(now), - endDate: formatDateToApiString(futureDate), - }; - }, []); - - const { data: ScheduleData, isLoading: scheduleLoading } = useSchedule( - startDate, - endDate - ); - - return ( -
-

다가오는 일정

- -
- {ScheduleData?.data.content.map((schedule: Schedule) => ( -
- - - {schedule.title} -
- {getDDay(schedule.dateTime)} -
-
-
- ))} -
-
- ); -} diff --git a/src/app/(afterLogin)/documents/[documentId]/_components/documentDescription.tsx b/src/app/(afterLogin)/documents/[documentId]/_components/documentDescription.tsx index 3623782..141efe8 100644 --- a/src/app/(afterLogin)/documents/[documentId]/_components/documentDescription.tsx +++ b/src/app/(afterLogin)/documents/[documentId]/_components/documentDescription.tsx @@ -1,10 +1,7 @@ "use client"; -import PlusCircleIcon from "@/assets/PlusCircle.svg"; import Divider from "../../_components/divider"; import DownloadIcon from "@/assets/Download.svg"; -import UploadIcon from "@/assets/Upload.svg"; -import Link from "next/link"; import { useDocumentStore } from "@/store/documents/documentStore"; import { ApplicationStatus, Document } from "@/type/applicationType"; import { DocumentType } from "@/type/documentType"; @@ -75,8 +72,7 @@ export default function DocumentDescription({ link.parentNode?.removeChild(link); window.URL.revokeObjectURL(blobUrl); - } catch (error) { - console.error("Download error:", error); + } catch { alert("파일 다운로드 중 오류가 발생했습니다."); } }; diff --git a/src/app/(afterLogin)/documents/_components/mainDocuments.tsx b/src/app/(afterLogin)/documents/_components/mainDocuments.tsx index b41af0e..0d4baa4 100644 --- a/src/app/(afterLogin)/documents/_components/mainDocuments.tsx +++ b/src/app/(afterLogin)/documents/_components/mainDocuments.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useRef, useEffect } from "react"; +import { useState, useRef, useEffect, useMemo } from "react"; import { formatDate } from "date-fns"; import { useCreateDocument, useDocuments } from "@/hooks/useDocuments"; import VersionBadge from "./versionBadge"; @@ -16,7 +16,7 @@ import UploadFileButton from "./uploadFileButton"; export default function MainDocuments() { const [page, setPage] = useState(0); const { data, error, isLoading } = useDocuments(page); - const documents = data?.data?.data.content ?? []; + const documents = useMemo(() => data?.data.data.content || [], [data?.data.data.content]); const router = useRouter(); const [isAdding, setIsAdding] = useState(false); From 30a6c59b2ec0203832c6e513899ad2977d3e115d Mon Sep 17 00:00:00 2001 From: strdeok Date: Thu, 30 Oct 2025 15:11:44 +0900 Subject: [PATCH 3/3] fix: build error fix --- src/hooks/useApplications.ts | 107 +++++++++++++++++++++-------------- 1 file changed, 65 insertions(+), 42 deletions(-) diff --git a/src/hooks/useApplications.ts b/src/hooks/useApplications.ts index cb79c89..bebe6b1 100644 --- a/src/hooks/useApplications.ts +++ b/src/hooks/useApplications.ts @@ -7,7 +7,7 @@ import { updateApplication, uploadApplications, } from "@/lib/applications"; -import { ApplicationStatus, CompanyApplication, CompanyApplicationWithId } from "@/type/applicationType"; +import { CompanyApplication, CompanyApplicationWithId } from "@/type/applicationType"; // 업로드 export const useUploadApplications = () => { @@ -18,8 +18,8 @@ export const useUploadApplications = () => { onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["applications"] }); }, - onError: (err) => { - console.error("지원서 업로드 실패:", err); + onError: () => { + console.error("지원서 업로드 실패:"); }, }); }; @@ -48,81 +48,104 @@ export const useFetchApplication = (applicationId: number) => { type UpdateApplicationVariables = { applicationId: number; - changedApplication: CompanyApplication; - queryKey?: (string | number)[]; + changedApplication: CompanyApplication; + queryKey?: (string | number)[]; newStatus?: string; }; +interface UpdateContext { + previousListData?: { + data: { content: CompanyApplicationWithId[] }; + }; + previousSingleData?: CompanyApplicationWithId; + queryKey?: (string | number)[]; + singleAppQueryKey: (string | number)[]; +} + // 상태 업데이트 훅 export const useUpdateApplication = () => { const queryClient = useQueryClient(); - return useMutation({ - mutationFn: (variables: UpdateApplicationVariables) => { - const { applicationId, changedApplication } = variables; - return updateApplication({ applicationId, changedApplication }); - }, + return useMutation< + void, + Error, + UpdateApplicationVariables, + UpdateContext + >({ + mutationFn: ({ applicationId, changedApplication }) => + updateApplication({ applicationId, changedApplication }), - onMutate: async (variables: UpdateApplicationVariables) => { - const { applicationId, queryKey, newStatus } = variables; - - const context: { - previousListData?: unknown; - queryKey?: (string | number)[]; - previousSingleData?: unknown; - singleAppQueryKey: (string | number)[]; - } = { - singleAppQueryKey: ["application", applicationId] + onMutate: async (variables) => { + const { applicationId, queryKey, newStatus, changedApplication } = variables; + + const context: UpdateContext = { + singleAppQueryKey: ["application", applicationId], }; await queryClient.cancelQueries({ queryKey: context.singleAppQueryKey }); - context.previousSingleData = queryClient.getQueryData(context.singleAppQueryKey); - queryClient.setQueryData(context.singleAppQueryKey, (oldData: any) => { - if (!oldData) return oldData; - return { - ...oldData, - data: { ...oldData.data, data: variables.changedApplication } - }; - }); + const previousSingle = queryClient.getQueryData( + context.singleAppQueryKey + ); + context.previousSingleData = previousSingle; + + if (previousSingle) { + queryClient.setQueryData( + context.singleAppQueryKey, + { + ...previousSingle, + ...changedApplication, + } + ); + } if (queryKey && newStatus) { await queryClient.cancelQueries({ queryKey }); - context.previousListData = queryClient.getQueryData(queryKey); + const previousList = queryClient.getQueryData<{ + data: { content: CompanyApplicationWithId[] }; + }>(queryKey); + context.previousListData = previousList; context.queryKey = queryKey; - queryClient.setQueryData(queryKey, (oldData: any) => { - if (!oldData) return oldData; - const updatedContent = oldData.data.content.map( - (item: CompanyApplicationWithId) => - item.id === applicationId ? { ...item, status: newStatus } : item + if (previousList?.data?.content) { + const updatedContent = previousList.data.content.map((item) => + item.id === applicationId ? { ...item, status: newStatus } : item ); - return { ...oldData, data: { ...oldData.data, content: updatedContent }}; - }); + queryClient.setQueryData(queryKey, { + ...previousList, + data: { ...previousList.data, content: updatedContent }, + }); + } } - + return context; }, - onError: (err, variables, context: any) => { + onError: (_err, _variables, context) => { alert("오류가 발생하였습니다."); + if (context?.previousSingleData) { - queryClient.setQueryData(context.singleAppQueryKey, context.previousSingleData); + queryClient.setQueryData( + context.singleAppQueryKey, + context.previousSingleData + ); } + if (context?.previousListData && context?.queryKey) { queryClient.setQueryData(context.queryKey, context.previousListData); } }, - onSettled: (data, error, variables, context: any) => { + onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ queryKey: ["applications"] }); - queryClient.invalidateQueries({ queryKey: ["application", variables.applicationId] }); + queryClient.invalidateQueries({ + queryKey: ["application", variables.applicationId], + }); queryClient.invalidateQueries({ queryKey: ["dashboard"] }); queryClient.invalidateQueries({ queryKey: ["schedule"] }); }, }); }; -// 삭제 export const useDeleteApplication = () => { const queryClient = useQueryClient(); return useMutation({