From 2794209ebb4b1476ad94993026a82a8625293a2c Mon Sep 17 00:00:00 2001 From: girimNam Date: Wed, 29 Jul 2026 23:35:02 +0900 Subject: [PATCH 01/10] =?UTF-8?q?refactor:=20=EA=B8=B0=EC=88=A0=20?= =?UTF-8?q?=EB=B6=84=EC=84=9D=20=ED=8E=98=EC=9D=B4=EC=A7=80=20api=20?= =?UTF-8?q?=ED=98=B8=EC=B6=9C=20=EB=B6=80=EB=B6=84=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../(main)/analysis/(workspace)/[id]/page.tsx | 122 +--------------- src/hooks/useAnalysisPriorArts.ts | 134 ++++++++++++++++++ 2 files changed, 138 insertions(+), 118 deletions(-) create mode 100644 src/hooks/useAnalysisPriorArts.ts diff --git a/src/app/(main)/analysis/(workspace)/[id]/page.tsx b/src/app/(main)/analysis/(workspace)/[id]/page.tsx index 542554a..de65619 100644 --- a/src/app/(main)/analysis/(workspace)/[id]/page.tsx +++ b/src/app/(main)/analysis/(workspace)/[id]/page.tsx @@ -1,130 +1,16 @@ "use client"; -import { use, useEffect, useState } from "react"; +import { use } from "react"; import { ProjectList } from "@/components/searchlist/ProjectList"; import { ResultListHeader } from "@/components/searchlist/ResultListHeader"; import { BackButton } from "@/components/ui/BackButton"; -import { getPriorArts, getPriorArtDetail, PRIOR_ARTS_ERROR_MESSAGES } from "@/lib/api/search"; -import { ApiError } from "@/lib/api/error"; import { RELEVANCE_LABEL, RELEVANCE_VARIANT } from "@/lib/priorArtRelevance"; -import { formatPeriod } from "@/lib/priorArtFormat"; -import { useAnalysisStore } from "@/store/analysisStore"; -import type { PriorArt } from "@/types/search.type"; - -// 선행문헌 상세 조회 api 에러코드별 메시지(주인용 선택 시 상세 조회에 사용) -const PRIOR_ART_DETAIL_ERROR_MESSAGES: Record = { - SC001: "인증이 필요합니다.", - CA002: "해당 사건에 접근할 권한이 없습니다.", - P001: "해당 선행기술을 찾을 수 없습니다.", - P003: "선행기술 문서를 찾을 수 없습니다.", - O001: "특허 검색 서버와 통신 중 오류가 발생했습니다.", - C002: "서버 내부 오류가 발생했습니다.", -}; +import { useAnalysisPriorArts } from "@/hooks/useAnalysisPriorArts"; export default function AnalysisDetailPage({ params }: { params: Promise<{ id: string }> }) { const { id } = use(params); - const selectedPatent = useAnalysisStore((state) => state.selectedPatent); - const setSelectedPatent = useAnalysisStore((state) => state.setSelectedPatent); - - const [priorArts, setPriorArts] = useState([]); - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(null); - const [selectingId, setSelectingId] = useState(null); - const [selectError, setSelectError] = useState(null); - - useEffect(() => { - setSelectedPatent(null); - - const clearSelectionOutsideProject = (event: PointerEvent) => { - if ( - event.target instanceof Element && - !event.target.closest("[data-analysis-project]") && - !event.target.closest("[data-analysis-right-panel]") - ) { - setSelectedPatent(null); - } - }; - - document.addEventListener("pointerdown", clearSelectionOutsideProject); - - return () => { - document.removeEventListener("pointerdown", clearSelectionOutsideProject); - setSelectedPatent(null); - }; - }, [setSelectedPatent]); - - //사건별 선행문헌 목록 조회 - useEffect(() => { - const caseId = Number(id); - if (!id || Number.isNaN(caseId)) return; - - let cancelled = false; - - getPriorArts(caseId) - .then((result) => { - if (cancelled) return; - setPriorArts(result.priorArts); - setError(null); - }) - .catch((err) => { - if (cancelled) return; - if (err instanceof ApiError) { - setError( - PRIOR_ARTS_ERROR_MESSAGES[err.errorCode] || - err.message || - "선행문헌 목록을 불러오는 중 오류가 발생했습니다." - ); - } else { - setError("선행문헌 목록을 불러오는 중 오류가 발생했습니다."); - } - }) - .finally(() => { - if (cancelled) return; - setIsLoading(false); - }); - - return () => { - cancelled = true; - }; - }, [id]); - - //주인용으로 선택 -> 선행문헌 상세 조회 후 우측 패널에 표시 - const handleSelect = async (priorArt: PriorArt) => { - setSelectError(null); - setSelectingId(priorArt.priorArtId); - - try { - const detail = await getPriorArtDetail(priorArt.priorArtId); - const legalStatusLabel = detail.legalStatus ?? "-"; - - setSelectedPatent({ - id: detail.priorArtId, - title: detail.title || "-", - patentNumber: detail.registrationNumber || detail.applicationNumber, - applicationNumber: detail.applicationNumber, - organization: detail.applicantName || "-", - applicationDate: detail.applicationDate || "-", - registrationDate: detail.registrationDate || "-", - applicationPeriod: formatPeriod(detail.applicationDate, detail.registrationDate), - currentStatus: legalStatusLabel, - summary: detail.summary || "-", - purpose: detail.techPurpose || "-", - mainFeatures: detail.keyFeatures.length > 0 ? detail.keyFeatures.join(", ") : "-", - }); - } catch (err) { - if (err instanceof ApiError) { - setSelectError( - PRIOR_ART_DETAIL_ERROR_MESSAGES[err.errorCode] || - err.message || - "선행문헌 상세를 불러오는 중 오류가 발생했습니다." - ); - } else { - setSelectError("선행문헌 상세를 불러오는 중 오류가 발생했습니다."); - } - } finally { - setSelectingId(null); - } - }; + const { selectedPatent, priorArts, isLoading, error, selectingId, selectError, handleSelect } = + useAnalysisPriorArts(id); return (
diff --git a/src/hooks/useAnalysisPriorArts.ts b/src/hooks/useAnalysisPriorArts.ts new file mode 100644 index 0000000..049aa78 --- /dev/null +++ b/src/hooks/useAnalysisPriorArts.ts @@ -0,0 +1,134 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { getPriorArts, getPriorArtDetail, PRIOR_ARTS_ERROR_MESSAGES } from "@/lib/api/search"; +import { ApiError } from "@/lib/api/error"; +import { formatPeriod } from "@/lib/priorArtFormat"; +import { useAnalysisStore } from "@/store/analysisStore"; +import type { PriorArt } from "@/types/search.type"; + +// 선행문헌 상세 조회 api 에러코드별 메시지(주인용 선택 시 상세 조회에 사용) +const PRIOR_ART_DETAIL_ERROR_MESSAGES: Record = { + SC001: "인증이 필요합니다.", + CA002: "해당 사건에 접근할 권한이 없습니다.", + P001: "해당 선행기술을 찾을 수 없습니다.", + P003: "선행기술 문서를 찾을 수 없습니다.", + O001: "특허 검색 서버와 통신 중 오류가 발생했습니다.", + C002: "서버 내부 오류가 발생했습니다.", +}; + +// 기술 분석 - 개별 사건 페이지: 선행문헌 목록 조회 + 주인용 선택(상세 조회 후 우측 패널에 표시) +export function useAnalysisPriorArts(id: string) { + const selectedPatent = useAnalysisStore((state) => state.selectedPatent); + const setSelectedPatent = useAnalysisStore((state) => state.setSelectedPatent); + + const [priorArts, setPriorArts] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [selectingId, setSelectingId] = useState(null); + const [selectError, setSelectError] = useState(null); + + useEffect(() => { + setSelectedPatent(null); + + const clearSelectionOutsideProject = (event: PointerEvent) => { + if ( + event.target instanceof Element && + !event.target.closest("[data-analysis-project]") && + !event.target.closest("[data-analysis-right-panel]") + ) { + setSelectedPatent(null); + } + }; + + document.addEventListener("pointerdown", clearSelectionOutsideProject); + + return () => { + document.removeEventListener("pointerdown", clearSelectionOutsideProject); + setSelectedPatent(null); + }; + }, [setSelectedPatent]); + + //사건별 선행문헌 목록 조회 + useEffect(() => { + const caseId = Number(id); + if (!id || Number.isNaN(caseId)) return; + + let cancelled = false; + + getPriorArts(caseId) + .then((result) => { + if (cancelled) return; + setPriorArts(result.priorArts); + setError(null); + }) + .catch((err) => { + if (cancelled) return; + if (err instanceof ApiError) { + setError( + PRIOR_ARTS_ERROR_MESSAGES[err.errorCode] || + err.message || + "선행문헌 목록을 불러오는 중 오류가 발생했습니다." + ); + } else { + setError("선행문헌 목록을 불러오는 중 오류가 발생했습니다."); + } + }) + .finally(() => { + if (cancelled) return; + setIsLoading(false); + }); + + return () => { + cancelled = true; + }; + }, [id]); + + //주인용으로 선택 -> 선행문헌 상세 조회 후 우측 패널에 표시 + const handleSelect = async (priorArt: PriorArt) => { + setSelectError(null); + setSelectingId(priorArt.priorArtId); + + try { + const detail = await getPriorArtDetail(priorArt.priorArtId); + const legalStatusLabel = detail.legalStatus ?? "-"; + + setSelectedPatent({ + id: detail.priorArtId, + title: detail.title || "-", + patentNumber: detail.registrationNumber || detail.applicationNumber, + applicationNumber: detail.applicationNumber, + organization: detail.applicantName || "-", + applicationDate: detail.applicationDate || "-", + registrationDate: detail.registrationDate || "-", + applicationPeriod: formatPeriod(detail.applicationDate, detail.registrationDate), + currentStatus: legalStatusLabel, + summary: detail.summary || "-", + purpose: detail.techPurpose || "-", + mainFeatures: detail.keyFeatures.length > 0 ? detail.keyFeatures.join(", ") : "-", + }); + } catch (err) { + if (err instanceof ApiError) { + setSelectError( + PRIOR_ART_DETAIL_ERROR_MESSAGES[err.errorCode] || + err.message || + "선행문헌 상세를 불러오는 중 오류가 발생했습니다." + ); + } else { + setSelectError("선행문헌 상세를 불러오는 중 오류가 발생했습니다."); + } + } finally { + setSelectingId(null); + } + }; + + return { + selectedPatent, + priorArts, + isLoading, + error, + selectingId, + selectError, + handleSelect, + }; +} From 7d37280d5a5a6465bf324b5a915f9a4932feb8bf Mon Sep 17 00:00:00 2001 From: girimNam Date: Wed, 29 Jul 2026 23:39:49 +0900 Subject: [PATCH 02/10] =?UTF-8?q?refactor:=20=EC=A7=84=EB=B3=B4=EC=84=B1?= =?UTF-8?q?=20=EB=B6=84=EC=84=9D=20=ED=8E=98=EC=9D=B4=EC=A7=80=20api=20?= =?UTF-8?q?=ED=98=B8=EC=B6=9C=20=EB=B6=80=EB=B6=84=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../(main)/analysis/[id]/[patentId]/page.tsx | 227 ++--------------- .../searchlist/ResultListHeader.tsx | 4 +- src/hooks/useInventiveStepAnalysis.ts | 233 ++++++++++++++++++ 3 files changed, 253 insertions(+), 211 deletions(-) create mode 100644 src/hooks/useInventiveStepAnalysis.ts diff --git a/src/app/(main)/analysis/[id]/[patentId]/page.tsx b/src/app/(main)/analysis/[id]/[patentId]/page.tsx index 50bae05..7f25760 100644 --- a/src/app/(main)/analysis/[id]/[patentId]/page.tsx +++ b/src/app/(main)/analysis/[id]/[patentId]/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { use, useEffect, useState } from "react"; +import { use } from "react"; import { useRouter } from "next/navigation"; import ComparisionPatentBox from "@/components/analysis/InventiveStep/Comparision/ComparisionPatentBox"; import { InventiveStepCard } from "@/components/analysis/InventiveStep/InventiveLogics/InventiveStepCard"; @@ -13,52 +13,12 @@ import { type InventiveStepLogicKey, } from "@/constants/analysis/inventiveStep"; import { Button } from "@/components/ui/Button"; -import { CreateReportForm } from "@/components/report/CreateReportForm"; -import { getInventiveStepAnalysis, updateInventiveArgument } from "@/lib/api/analysis"; -import { ApiError } from "@/lib/api/error"; -import { useAuthStore } from "@/store/authStore"; +import { getArgument, useInventiveStepAnalysis } from "@/hooks/useInventiveStepAnalysis"; import type { InventiveStepAnalysisResponse, - InventiveStepArgument, InventiveStepArgumentContent, - UpdateInventiveArgumentRequest, } from "@/types/inventiveStep.type"; -const ARGUMENT_TYPE_TO_LOGIC_KEY: Record< - InventiveStepArgument["argumentType"], - InventiveStepLogicKey -> = { - NUMERICAL_LIMIT: "numericLimitation", - COMBINATION_MOTIVATION: "multiReferenceCombination", - COMMON_TECHNIQUE: "commonKnowledge", - SIMPLE_DESIGN: "simpleDesignChange", -}; - -const LOGIC_KEY_TO_ARGUMENT_TYPE: Record< - InventiveStepLogicKey, - InventiveStepArgument["argumentType"] -> = { - numericLimitation: "NUMERICAL_LIMIT", - multiReferenceCombination: "COMBINATION_MOTIVATION", - commonKnowledge: "COMMON_TECHNIQUE", - simpleDesignChange: "SIMPLE_DESIGN", -}; - -const INVENTIVE_STEP_ERROR_MESSAGES: Record = { - SC001: "로그인이 필요합니다.", - CA002: "해당 사건에 접근할 권한이 없습니다.", - CA001: "사건을 찾을 수 없습니다.", - I003: "진보성 분석 결과가 존재하지 않습니다.", - I004: "수정할 진보성 논리 항목을 찾을 수 없습니다.", - C001: "수정할 내용을 확인해주세요.", - C002: "서버 내부 오류가 발생했습니다. 잠시 후 다시 시도해주세요.", -}; - -function getArgument(analysis: InventiveStepAnalysisResponse, logicKey: InventiveStepLogicKey) { - const argumentType = LOGIC_KEY_TO_ARGUMENT_TYPE[logicKey]; - return analysis.arguments.find((argument) => argument.argumentType === argumentType); -} - function renderArgumentForm( analysis: InventiveStepAnalysisResponse, logicKey: InventiveStepLogicKey, @@ -137,162 +97,21 @@ export default function AnalysisReportPage({ }) { const { id } = use(params); const router = useRouter(); - const isAuthInitialized = useAuthStore((state) => state.isInitialized); - const accessToken = useAuthStore((state) => state.accessToken); - const [result, setResult] = useState<{ - caseId: string; - analysis: InventiveStepAnalysisResponse; - } | null>(null); - const [requestError, setRequestError] = useState<{ - caseId: string; - message: string; - } | null>(null); - const [reloadCount, setReloadCount] = useState(0); - const [selectedLogics, setSelectedLogics] = useState>(new Set()); - // AI가 최초 분석 시 추천한 논리 카테고리(argumentId) 고정 스냅샷 -> 이후 사용자가 선택/해제해도 "AI 추천" 배지는 바뀌면 안 됨 - const [aiRecommendedArgumentIds, setAiRecommendedArgumentIds] = useState>(new Set()); - const [placeholderArgumentIds, setPlaceholderArgumentIds] = useState>(new Set()); - const [updatingLogics, setUpdatingLogics] = useState>(new Set()); - const [updateError, setUpdateError] = useState(null); - - useEffect(() => { - if (!isAuthInitialized || !accessToken) return; - - let canceled = false; - - getInventiveStepAnalysis(id) - .then((data) => { - if (canceled) return; - - setRequestError(null); - setResult({ caseId: id, analysis: data }); - setSelectedLogics( - new Set( - data.arguments - .filter((argument) => argument.recommended) - .map((argument) => ARGUMENT_TYPE_TO_LOGIC_KEY[argument.argumentType]) - ) - ); - setAiRecommendedArgumentIds( - new Set( - data.arguments.filter((argument) => argument.recommended).map((a) => a.argumentId) - ) - ); - setPlaceholderArgumentIds( - new Set( - data.arguments - .filter((argument) => !argument.recommended) - .map((argument) => argument.argumentId) - ) - ); - }) - .catch((error) => { - if (canceled) return; - - setResult(null); - setRequestError({ - caseId: id, - message: - error instanceof ApiError - ? (INVENTIVE_STEP_ERROR_MESSAGES[error.errorCode] ?? error.message) - : "진보성 분석 결과를 불러오는 중 네트워크 오류가 발생했습니다.", - }); - }); - - return () => { - canceled = true; - }; - }, [accessToken, id, isAuthInitialized, reloadCount]); - - const analysis = result?.caseId === id ? result.analysis : null; - const errorMessage = - !isAuthInitialized || accessToken - ? requestError?.caseId === id - ? requestError.message - : null - : INVENTIVE_STEP_ERROR_MESSAGES.SC001; - - const applyArgumentUpdate = ( - updatedArgument: InventiveStepArgument, - body: UpdateInventiveArgumentRequest - ) => { - setResult((previous) => { - if (!previous || previous.caseId !== id) return previous; - - return { - ...previous, - analysis: { - ...previous.analysis, - arguments: previous.analysis.arguments.map((argument) => - argument.argumentId === updatedArgument.argumentId - ? { - ...argument, - recommended: updatedArgument.recommended, - ...(body.content ? { content: body.content } : {}), - } - : argument - ) as InventiveStepArgument[], - }, - }; - }); - }; - - const saveArgument = async (argumentId: number, body: UpdateInventiveArgumentRequest) => { - try { - const updatedArgument = await updateInventiveArgument(argumentId, body); - applyArgumentUpdate(updatedArgument, body); - } catch (error) { - const message = - error instanceof ApiError - ? (INVENTIVE_STEP_ERROR_MESSAGES[error.errorCode] ?? error.message) - : "진보성 논리 수정 중 네트워크 오류가 발생했습니다."; - throw new Error(message); - } - }; - - const saveArgumentContent = async (argumentId: number, content: InventiveStepArgumentContent) => { - await saveArgument(argumentId, { content }); - setPlaceholderArgumentIds((previous) => { - const next = new Set(previous); - next.delete(argumentId); - return next; - }); - }; - - const toggleLogic = async (key: InventiveStepLogicKey) => { - if (!analysis || updatingLogics.has(key)) return; - - const argument = getArgument(analysis, key); - if (!argument) return; - - const nextRecommended = !selectedLogics.has(key); - - setUpdatingLogics((previous) => new Set(previous).add(key)); - setUpdateError(null); - - try { - await saveArgument(argument.argumentId, { recommended: nextRecommended }); - setSelectedLogics((previous) => { - const next = new Set(previous); - if (nextRecommended) { - next.add(key); - } else { - next.delete(key); - } - return next; - }); - } catch (error) { - setUpdateError(error instanceof Error ? error.message : "선택 상태를 저장하지 못했습니다."); - } finally { - setUpdatingLogics((previous) => { - const next = new Set(previous); - next.delete(key); - return next; - }); - } - }; - - if (!isAuthInitialized || (accessToken && !analysis && !errorMessage)) { + const { + analysis, + errorMessage, + isLoading, + selectedLogics, + aiRecommendedArgumentIds, + placeholderArgumentIds, + updatingLogics, + updateError, + toggleLogic, + saveArgumentContent, + reload, + } = useInventiveStepAnalysis(id); + + if (isLoading) { return (

진보성 분석 결과를 불러오고 있습니다...

@@ -306,17 +125,7 @@ export default function AnalysisReportPage({

{errorMessage ?? "진보성 분석 결과를 불러오지 못했습니다."}

-
diff --git a/src/components/searchlist/ResultListHeader.tsx b/src/components/searchlist/ResultListHeader.tsx index f4e652f..0ac636b 100644 --- a/src/components/searchlist/ResultListHeader.tsx +++ b/src/components/searchlist/ResultListHeader.tsx @@ -31,8 +31,8 @@ export const ResultListHeader = forwardRef = { + NUMERICAL_LIMIT: "numericLimitation", + COMBINATION_MOTIVATION: "multiReferenceCombination", + COMMON_TECHNIQUE: "commonKnowledge", + SIMPLE_DESIGN: "simpleDesignChange", +}; + +const LOGIC_KEY_TO_ARGUMENT_TYPE: Record< + InventiveStepLogicKey, + InventiveStepArgument["argumentType"] +> = { + numericLimitation: "NUMERICAL_LIMIT", + multiReferenceCombination: "COMBINATION_MOTIVATION", + commonKnowledge: "COMMON_TECHNIQUE", + simpleDesignChange: "SIMPLE_DESIGN", +}; + +const INVENTIVE_STEP_ERROR_MESSAGES: Record = { + SC001: "로그인이 필요합니다.", + CA002: "해당 사건에 접근할 권한이 없습니다.", + CA001: "사건을 찾을 수 없습니다.", + I003: "진보성 분석 결과가 존재하지 않습니다.", + I004: "수정할 진보성 논리 항목을 찾을 수 없습니다.", + C001: "수정할 내용을 확인해주세요.", + C002: "서버 내부 오류가 발생했습니다. 잠시 후 다시 시도해주세요.", +}; + +export function getArgument( + analysis: InventiveStepAnalysisResponse, + logicKey: InventiveStepLogicKey +) { + const argumentType = LOGIC_KEY_TO_ARGUMENT_TYPE[logicKey]; + return analysis.arguments.find((argument) => argument.argumentType === argumentType); +} + +// 개별 특허 진보성 분석 페이지: 분석 결과 조회 + 논리 선택/수정 저장 +export function useInventiveStepAnalysis(id: string) { + const isAuthInitialized = useAuthStore((state) => state.isInitialized); + const accessToken = useAuthStore((state) => state.accessToken); + + const [result, setResult] = useState<{ + caseId: string; + analysis: InventiveStepAnalysisResponse; + } | null>(null); + const [requestError, setRequestError] = useState<{ + caseId: string; + message: string; + } | null>(null); + const [reloadCount, setReloadCount] = useState(0); + const [selectedLogics, setSelectedLogics] = useState>(new Set()); + // AI가 최초 분석 시 추천한 논리 카테고리(argumentId) 고정 스냅샷 -> 이후 사용자가 선택/해제해도 "AI 추천" 배지는 바뀌면 안 됨 + const [aiRecommendedArgumentIds, setAiRecommendedArgumentIds] = useState>(new Set()); + const [placeholderArgumentIds, setPlaceholderArgumentIds] = useState>(new Set()); + const [updatingLogics, setUpdatingLogics] = useState>(new Set()); + const [updateError, setUpdateError] = useState(null); + + useEffect(() => { + if (!isAuthInitialized || !accessToken) return; + + let canceled = false; + + getInventiveStepAnalysis(id) + .then((data) => { + if (canceled) return; + + setRequestError(null); + setResult({ caseId: id, analysis: data }); + setSelectedLogics( + new Set( + data.arguments + .filter((argument) => argument.recommended) + .map((argument) => ARGUMENT_TYPE_TO_LOGIC_KEY[argument.argumentType]) + ) + ); + setAiRecommendedArgumentIds( + new Set( + data.arguments.filter((argument) => argument.recommended).map((a) => a.argumentId) + ) + ); + setPlaceholderArgumentIds( + new Set( + data.arguments + .filter((argument) => !argument.recommended) + .map((argument) => argument.argumentId) + ) + ); + }) + .catch((error) => { + if (canceled) return; + + setResult(null); + setRequestError({ + caseId: id, + message: + error instanceof ApiError + ? (INVENTIVE_STEP_ERROR_MESSAGES[error.errorCode] ?? error.message) + : "진보성 분석 결과를 불러오는 중 네트워크 오류가 발생했습니다.", + }); + }); + + return () => { + canceled = true; + }; + }, [accessToken, id, isAuthInitialized, reloadCount]); + + const analysis = result?.caseId === id ? result.analysis : null; + const errorMessage = + !isAuthInitialized || accessToken + ? requestError?.caseId === id + ? requestError.message + : null + : INVENTIVE_STEP_ERROR_MESSAGES.SC001; + const isLoading = !isAuthInitialized || Boolean(accessToken && !analysis && !errorMessage); + + const applyArgumentUpdate = ( + updatedArgument: InventiveStepArgument, + body: UpdateInventiveArgumentRequest + ) => { + setResult((previous) => { + if (!previous || previous.caseId !== id) return previous; + + return { + ...previous, + analysis: { + ...previous.analysis, + arguments: previous.analysis.arguments.map((argument) => + argument.argumentId === updatedArgument.argumentId + ? { + ...argument, + recommended: updatedArgument.recommended, + ...(body.content ? { content: body.content } : {}), + } + : argument + ) as InventiveStepArgument[], + }, + }; + }); + }; + + const saveArgument = async (argumentId: number, body: UpdateInventiveArgumentRequest) => { + try { + const updatedArgument = await updateInventiveArgument(argumentId, body); + applyArgumentUpdate(updatedArgument, body); + } catch (error) { + const message = + error instanceof ApiError + ? (INVENTIVE_STEP_ERROR_MESSAGES[error.errorCode] ?? error.message) + : "진보성 논리 수정 중 네트워크 오류가 발생했습니다."; + throw new Error(message); + } + }; + + const saveArgumentContent = async (argumentId: number, content: InventiveStepArgumentContent) => { + await saveArgument(argumentId, { content }); + setPlaceholderArgumentIds((previous) => { + const next = new Set(previous); + next.delete(argumentId); + return next; + }); + }; + + const toggleLogic = async (key: InventiveStepLogicKey) => { + if (!analysis || updatingLogics.has(key)) return; + + const argument = getArgument(analysis, key); + if (!argument) return; + + const nextRecommended = !selectedLogics.has(key); + + setUpdatingLogics((previous) => new Set(previous).add(key)); + setUpdateError(null); + + try { + await saveArgument(argument.argumentId, { recommended: nextRecommended }); + setSelectedLogics((previous) => { + const next = new Set(previous); + if (nextRecommended) { + next.add(key); + } else { + next.delete(key); + } + return next; + }); + } catch (error) { + setUpdateError(error instanceof Error ? error.message : "선택 상태를 저장하지 못했습니다."); + } finally { + setUpdatingLogics((previous) => { + const next = new Set(previous); + next.delete(key); + return next; + }); + } + }; + + const reload = () => { + setResult(null); + setRequestError(null); + setSelectedLogics(new Set()); + setPlaceholderArgumentIds(new Set()); + setReloadCount((count) => count + 1); + }; + + return { + analysis, + errorMessage, + isLoading, + selectedLogics, + aiRecommendedArgumentIds, + placeholderArgumentIds, + updatingLogics, + updateError, + toggleLogic, + saveArgumentContent, + reload, + }; +} From c9049c31bb6d0dd94630b7fb940cfe4fb3e55618 Mon Sep 17 00:00:00 2001 From: girimNam Date: Wed, 29 Jul 2026 23:42:09 +0900 Subject: [PATCH 03/10] =?UTF-8?q?refactor:=20=EB=82=B4=20=ED=99=9C?= =?UTF-8?q?=EB=8F=99=EA=B8=B0=EB=A1=9D=20=EA=B4=80=EB=A0=A8=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20api=20=ED=98=B8=EC=B6=9C=20=EB=B6=80?= =?UTF-8?q?=EB=B6=84=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../(main)/myhistory/[id]/novelty/page.tsx | 120 +----------------- src/hooks/useNoveltyAnalysis.ts | 117 +++++++++++++++++ 2 files changed, 124 insertions(+), 113 deletions(-) create mode 100644 src/hooks/useNoveltyAnalysis.ts diff --git a/src/app/(main)/myhistory/[id]/novelty/page.tsx b/src/app/(main)/myhistory/[id]/novelty/page.tsx index 97cc783..5d1f00b 100644 --- a/src/app/(main)/myhistory/[id]/novelty/page.tsx +++ b/src/app/(main)/myhistory/[id]/novelty/page.tsx @@ -1,19 +1,13 @@ "use client"; -import { use, useEffect, useState } from "react"; +import { use } from "react"; import { Button } from "@/components/ui/Button"; import { BackButton } from "@/components/ui/BackButton"; import Header from "@/components/myhistory/novelty/Header"; import Similarity from "@/components/myhistory/novelty/Similarity"; import NoveltyTable from "@/components/myhistory/novelty/NoveltyTable"; -import { getNoveltyAnalysis, updateNoveltyComparison } from "@/lib/api/analysis"; -import { ApiError } from "@/lib/api/error"; -import { useAuthStore } from "@/store/authStore"; -import type { - NoveltyAnalysisResponse, - NoveltyOverallSimilarity, - UpdateNoveltyComparisonRequest, -} from "@/types/novelty.type"; +import { useNoveltyAnalysis } from "@/hooks/useNoveltyAnalysis"; +import type { NoveltyOverallSimilarity } from "@/types/novelty.type"; const SIMILARITY_LABELS: Record = { VERY_HIGH: "매우 높음", @@ -23,103 +17,11 @@ const SIMILARITY_LABELS: Record = { VERY_LOW: "매우 낮음", }; -const NOVELTY_ANALYSIS_ERROR_MESSAGES: Record = { - C001: "수정할 내용을 확인해주세요.", - SC001: "로그인이 필요합니다.", - CA002: "해당 사건에 접근할 권한이 없습니다.", - CA001: "사건을 찾을 수 없습니다.", - N001: "신규성 분석 결과가 존재하지 않습니다.", - N002: "수정할 신규성 비교 결과를 찾을 수 없습니다.", - C002: "서버 내부 오류가 발생했습니다. 잠시 후 다시 시도해주세요.", -}; - export default function NoveltyPage({ params }: { params: Promise<{ id: string }> }) { const { id } = use(params); - const isAuthInitialized = useAuthStore((state) => state.isInitialized); - const accessToken = useAuthStore((state) => state.accessToken); - const [result, setResult] = useState<{ - caseId: string; - analysis: NoveltyAnalysisResponse; - } | null>(null); - const [requestError, setRequestError] = useState<{ - caseId: string; - message: string; - } | null>(null); - const [reloadCount, setReloadCount] = useState(0); - - useEffect(() => { - if (!isAuthInitialized || !accessToken) return; - - let canceled = false; - - getNoveltyAnalysis(id) - .then((data) => { - if (canceled) return; - setRequestError(null); - setResult({ caseId: id, analysis: data }); - }) - .catch((error) => { - if (canceled) return; - - setResult(null); - setRequestError({ - caseId: id, - message: - error instanceof ApiError - ? (NOVELTY_ANALYSIS_ERROR_MESSAGES[error.errorCode] ?? error.message) - : "신규성 분석 결과를 불러오는 중 네트워크 오류가 발생했습니다.", - }); - }); - - return () => { - canceled = true; - }; - }, [accessToken, id, isAuthInitialized, reloadCount]); - - const analysis = result?.caseId === id ? result.analysis : null; - const errorMessage = - !isAuthInitialized || accessToken - ? requestError?.caseId === id - ? requestError.message - : null - : NOVELTY_ANALYSIS_ERROR_MESSAGES.SC001; - - const handleComparisonSave = async ( - comparisonId: number, - body: UpdateNoveltyComparisonRequest - ) => { - try { - const updated = await updateNoveltyComparison(comparisonId, body); - - setResult((previous) => { - if (!previous || previous.caseId !== id) return previous; - - return { - ...previous, - analysis: { - ...previous.analysis, - comparisons: previous.analysis.comparisons.map((comparison) => - comparison.comparisonId === updated.comparisonId - ? { - ...comparison, - comparisonResult: updated.comparisonResult, - citation: updated.citation !== undefined ? updated.citation : body.citation, - } - : comparison - ), - }, - }; - }); - } catch (error) { - const message = - error instanceof ApiError - ? (NOVELTY_ANALYSIS_ERROR_MESSAGES[error.errorCode] ?? error.message) - : "신규성 비교 결과 수정 중 네트워크 오류가 발생했습니다."; - throw new Error(message); - } - }; + const { analysis, errorMessage, isLoading, saveComparison, reload } = useNoveltyAnalysis(id); - if (!isAuthInitialized || (accessToken && !analysis && !errorMessage)) { + if (isLoading) { return (

신규성 분석 결과를 불러오고 있습니다...

@@ -138,15 +40,7 @@ export default function NoveltyPage({ params }: { params: Promise<{ id: string }

{errorMessage ?? "신규성 분석 결과를 불러오지 못했습니다."}

-
@@ -170,7 +64,7 @@ export default function NoveltyPage({ params }: { params: Promise<{ id: string } reason={analysis.conclusionText} /> - +
); } diff --git a/src/hooks/useNoveltyAnalysis.ts b/src/hooks/useNoveltyAnalysis.ts new file mode 100644 index 0000000..e7ae674 --- /dev/null +++ b/src/hooks/useNoveltyAnalysis.ts @@ -0,0 +1,117 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { getNoveltyAnalysis, updateNoveltyComparison } from "@/lib/api/analysis"; +import { ApiError } from "@/lib/api/error"; +import { useAuthStore } from "@/store/authStore"; +import type { NoveltyAnalysisResponse, UpdateNoveltyComparisonRequest } from "@/types/novelty.type"; + +const NOVELTY_ANALYSIS_ERROR_MESSAGES: Record = { + C001: "수정할 내용을 확인해주세요.", + SC001: "로그인이 필요합니다.", + CA002: "해당 사건에 접근할 권한이 없습니다.", + CA001: "사건을 찾을 수 없습니다.", + N001: "신규성 분석 결과가 존재하지 않습니다.", + N002: "수정할 신규성 비교 결과를 찾을 수 없습니다.", + C002: "서버 내부 오류가 발생했습니다. 잠시 후 다시 시도해주세요.", +}; + +// 신규성 분석 페이지: 분석 결과 조회 + 비교 결과 수정 저장 +export function useNoveltyAnalysis(id: string) { + const isAuthInitialized = useAuthStore((state) => state.isInitialized); + const accessToken = useAuthStore((state) => state.accessToken); + + const [result, setResult] = useState<{ + caseId: string; + analysis: NoveltyAnalysisResponse; + } | null>(null); + const [requestError, setRequestError] = useState<{ + caseId: string; + message: string; + } | null>(null); + const [reloadCount, setReloadCount] = useState(0); + + useEffect(() => { + if (!isAuthInitialized || !accessToken) return; + + let canceled = false; + + getNoveltyAnalysis(id) + .then((data) => { + if (canceled) return; + setRequestError(null); + setResult({ caseId: id, analysis: data }); + }) + .catch((error) => { + if (canceled) return; + + setResult(null); + setRequestError({ + caseId: id, + message: + error instanceof ApiError + ? (NOVELTY_ANALYSIS_ERROR_MESSAGES[error.errorCode] ?? error.message) + : "신규성 분석 결과를 불러오는 중 네트워크 오류가 발생했습니다.", + }); + }); + + return () => { + canceled = true; + }; + }, [accessToken, id, isAuthInitialized, reloadCount]); + + const analysis = result?.caseId === id ? result.analysis : null; + const errorMessage = + !isAuthInitialized || accessToken + ? requestError?.caseId === id + ? requestError.message + : null + : NOVELTY_ANALYSIS_ERROR_MESSAGES.SC001; + const isLoading = !isAuthInitialized || Boolean(accessToken && !analysis && !errorMessage); + + const saveComparison = async (comparisonId: number, body: UpdateNoveltyComparisonRequest) => { + try { + const updated = await updateNoveltyComparison(comparisonId, body); + + setResult((previous) => { + if (!previous || previous.caseId !== id) return previous; + + return { + ...previous, + analysis: { + ...previous.analysis, + comparisons: previous.analysis.comparisons.map((comparison) => + comparison.comparisonId === updated.comparisonId + ? { + ...comparison, + comparisonResult: updated.comparisonResult, + citation: updated.citation !== undefined ? updated.citation : body.citation, + } + : comparison + ), + }, + }; + }); + } catch (error) { + const message = + error instanceof ApiError + ? (NOVELTY_ANALYSIS_ERROR_MESSAGES[error.errorCode] ?? error.message) + : "신규성 비교 결과 수정 중 네트워크 오류가 발생했습니다."; + throw new Error(message); + } + }; + + const reload = () => { + setResult(null); + setRequestError(null); + setReloadCount((count) => count + 1); + }; + + return { + analysis, + errorMessage, + isLoading, + saveComparison, + reload, + }; +} From 5b1b416f142a5f982fe581daa9b800e390eee869 Mon Sep 17 00:00:00 2001 From: girimNam Date: Wed, 29 Jul 2026 23:46:49 +0900 Subject: [PATCH 04/10] =?UTF-8?q?refactor:=20=ED=83=90=EC=83=89=20?= =?UTF-8?q?=EC=A7=84=ED=96=89=20=EB=A1=9C=EB=94=A9=EC=A4=91=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20api=20=ED=98=B8=EC=B6=9C=20=EB=B6=80?= =?UTF-8?q?=EB=B6=84=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/(main)/search/loading/page.tsx | 143 +------------------------ src/hooks/useSearchProgress.ts | 140 ++++++++++++++++++++++++ 2 files changed, 144 insertions(+), 139 deletions(-) create mode 100644 src/hooks/useSearchProgress.ts diff --git a/src/app/(main)/search/loading/page.tsx b/src/app/(main)/search/loading/page.tsx index b903c13..c0b2d75 100644 --- a/src/app/(main)/search/loading/page.tsx +++ b/src/app/(main)/search/loading/page.tsx @@ -1,48 +1,13 @@ "use client"; -import { Suspense, useEffect, useId, useState } from "react"; -import { useRouter, useSearchParams } from "next/navigation"; +import { Suspense, useId } from "react"; import StopIcon from "@/components/icons/icon-stop.svg"; import { Button } from "@/components/ui/Button"; -import { cancelSearch, getSearchStatus } from "@/lib/api/search"; -import { ApiError } from "@/lib/api/error"; -import type { SearchStatusResponse } from "@/types/search.type"; - -const DEFAULT_RESULT_COUNT = 10; -const POLL_INTERVAL_MS = 2000; -const MAX_POLL_RETRIES = 5; - -// 탐색 진행률 조회 api 에러코드별 메시지 -const SEARCH_STATUS_ERROR_MESSAGES: Record = { - SC001: "인증이 필요합니다.", - CA001: "사건을 찾을 수 없습니다.", - CA002: "해당 사건에 접근할 권한이 없습니다.", - S001: "검색 정보를 찾을 수 없습니다.", - R001: "일시적인 시스템 오류입니다. 잠시 후 다시 시도해주세요.", - C002: "서버 내부 오류가 발생했습니다.", -}; - -// 탐색 중단 api 에러코드별 메시지 (조회 api와 코드 구성이 동일) -const SEARCH_CANCEL_ERROR_MESSAGES = SEARCH_STATUS_ERROR_MESSAGES; - -// 이 부분은 테스트용! -const getMockSteps = (resultCount: number) => [ - { percent: 12, label: "검색 의도 해석 중" }, - { percent: 34, label: "선행 특허 탐색 중" }, - { percent: 70, label: `${resultCount}개의 기술을 찾고 있어요` }, - { percent: 100, label: `${resultCount}개의 기술을 찾고 있어요` }, -]; +import { useSearchProgress } from "@/hooks/useSearchProgress"; const RADIUS = 88; const CIRCUMFERENCE = 2 * Math.PI * RADIUS; -const TERMINAL_MESSAGE: Record string> = { - no_results: () => "조건에 맞는 선행기술을 찾지 못했어요.", - invalid_input: (status) => status.reasonInvalid || "입력하신 내용을 다시 확인해주세요.", - failed: (status) => status.error || status.reasonInvalid || "탐색 중 오류가 발생했습니다.", - cancelled: () => "탐색이 취소되었습니다.", -}; - function ProgressRing({ percent }: { percent: number }) { const offset = CIRCUMFERENCE - (percent / 100) * CIRCUMFERENCE; const gradientId = useId(); @@ -92,108 +57,8 @@ function ProgressRing({ percent }: { percent: number }) { } function LoadingContent() { - const router = useRouter(); - const searchParams = useSearchParams(); - const resultCount = Number(searchParams.get("count")) || DEFAULT_RESULT_COUNT; - const caseId = searchParams.get("caseId"); - const title = searchParams.get("title"); - - const [status, setStatus] = useState(null); - const [pollError, setPollError] = useState(null); - const [cancelError, setCancelError] = useState(null); - const [mockStepIndex, setMockStepIndex] = useState(0); - const [isStopping, setIsStopping] = useState(false); - const mockSteps = getMockSteps(resultCount); - - const handleStop = async () => { - if (!caseId) { - router.push("/search?resume=1"); - return; - } - - setCancelError(null); - setIsStopping(true); - try { - await cancelSearch(Number(caseId)); - router.push("/search?resume=1"); - } catch (err) { - if (err instanceof ApiError) { - setCancelError( - SEARCH_CANCEL_ERROR_MESSAGES[err.errorCode] || - err.message || - "탐색 중단 중 오류가 발생했습니다." - ); - } else { - setCancelError("탐색 중단 중 오류가 발생했습니다."); - } - } finally { - setIsStopping(false); - } - }; - - useEffect(() => { - if (!caseId || isStopping) return; - - let cancelled = false; - let timer: ReturnType; - let consecutiveErrors = 0; - - const poll = async () => { - try { - const result = await getSearchStatus(Number(caseId)); - if (cancelled || isStopping) return; - consecutiveErrors = 0; - setStatus(result); - setPollError(null); - - if (result.status === "in_progress") { - timer = setTimeout(poll, POLL_INTERVAL_MS); - } else if (result.status === "completed") { - router.push( - `/search/result?caseId=${caseId}${title ? `&title=${encodeURIComponent(title)}` : ""}` - ); - } - } catch (err) { - if (cancelled) return; - consecutiveErrors += 1; - - const message = - err instanceof ApiError - ? SEARCH_STATUS_ERROR_MESSAGES[err.errorCode] || - err.message || - "진행 상태 조회 중 오류가 발생했습니다." - : "진행 상태 조회 중 오류가 발생했습니다."; - - if (consecutiveErrors < MAX_POLL_RETRIES) { - // 일시적인 네트워크 오류일 수 있으니 바로 포기하지 않고 재시도 - setPollError(message); - timer = setTimeout(poll, POLL_INTERVAL_MS); - } else { - setPollError(message); - } - } - }; - - poll(); - return () => { - cancelled = true; - clearTimeout(timer); - }; - }, [caseId, isStopping, router, title]); - - useEffect(() => { - if (caseId) return; - if (mockStepIndex >= mockSteps.length - 1) return; - const timer = setTimeout(() => setMockStepIndex((prev) => prev + 1), 1200); - return () => clearTimeout(timer); - }, [caseId, mockStepIndex, mockSteps.length]); - - const percent = caseId ? (status?.progress ?? 0) : mockSteps[mockStepIndex].percent; - const label = caseId ? (status?.step ?? "탐색 준비 중") : mockSteps[mockStepIndex].label; - const terminalMessage = - caseId && status && status.status !== "in_progress" && status.status !== "completed" - ? TERMINAL_MESSAGE[status.status]?.(status) - : null; + const { percent, label, terminalMessage, pollError, cancelError, isStopping, handleStop } = + useSearchProgress(); return (
diff --git a/src/hooks/useSearchProgress.ts b/src/hooks/useSearchProgress.ts new file mode 100644 index 0000000..0a43f81 --- /dev/null +++ b/src/hooks/useSearchProgress.ts @@ -0,0 +1,140 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { cancelSearch, getSearchStatus } from "@/lib/api/search"; +import { ApiError } from "@/lib/api/error"; +import type { SearchStatusResponse } from "@/types/search.type"; + +const POLL_INTERVAL_MS = 2000; +const MAX_POLL_RETRIES = 5; + +// 탐색 진행률 조회 api 에러코드별 메시지 +const SEARCH_STATUS_ERROR_MESSAGES: Record = { + SC001: "인증이 필요합니다.", + CA001: "사건을 찾을 수 없습니다.", + CA002: "해당 사건에 접근할 권한이 없습니다.", + S001: "검색 정보를 찾을 수 없습니다.", + R001: "일시적인 시스템 오류입니다. 잠시 후 다시 시도해주세요.", + C002: "서버 내부 오류가 발생했습니다.", +}; + +// 탐색 중단 api 에러코드별 메시지 (조회 api와 코드 구성이 동일) +const SEARCH_CANCEL_ERROR_MESSAGES = SEARCH_STATUS_ERROR_MESSAGES; + +const TERMINAL_MESSAGE: Record string> = { + no_results: () => "조건에 맞는 선행기술을 찾지 못했어요.", + invalid_input: (status) => status.reasonInvalid || "입력하신 내용을 다시 확인해주세요.", + failed: (status) => status.error || status.reasonInvalid || "탐색 중 오류가 발생했습니다.", + cancelled: () => "탐색이 취소되었습니다.", +}; + +// 선행기술 탐색 로딩 페이지: 진행 상태 폴링 + 중단하기 +export function useSearchProgress() { + const router = useRouter(); + const searchParams = useSearchParams(); + const caseId = searchParams.get("caseId"); + const title = searchParams.get("title"); + + const [status, setStatus] = useState(null); + const [pollError, setPollError] = useState(null); + const [cancelError, setCancelError] = useState(null); + const [isStopping, setIsStopping] = useState(false); + + const handleStop = async () => { + if (!caseId) { + router.push("/search?resume=1"); + return; + } + + setCancelError(null); + setIsStopping(true); + try { + await cancelSearch(Number(caseId)); + router.push("/search?resume=1"); + } catch (err) { + if (err instanceof ApiError) { + setCancelError( + SEARCH_CANCEL_ERROR_MESSAGES[err.errorCode] || + err.message || + "탐색 중단 중 오류가 발생했습니다." + ); + } else { + setCancelError("탐색 중단 중 오류가 발생했습니다."); + } + } finally { + setIsStopping(false); + } + }; + + useEffect(() => { + if (!caseId) { + router.push("/search?resume=1"); + return; + } + if (isStopping) return; + + let cancelled = false; + let timer: ReturnType; + let consecutiveErrors = 0; + + const poll = async () => { + try { + const result = await getSearchStatus(Number(caseId)); + if (cancelled || isStopping) return; + consecutiveErrors = 0; + setStatus(result); + setPollError(null); + + if (result.status === "in_progress") { + timer = setTimeout(poll, POLL_INTERVAL_MS); + } else if (result.status === "completed") { + router.push( + `/search/result?caseId=${caseId}${title ? `&title=${encodeURIComponent(title)}` : ""}` + ); + } + } catch (err) { + if (cancelled) return; + consecutiveErrors += 1; + + const message = + err instanceof ApiError + ? SEARCH_STATUS_ERROR_MESSAGES[err.errorCode] || + err.message || + "진행 상태 조회 중 오류가 발생했습니다." + : "진행 상태 조회 중 오류가 발생했습니다."; + + if (consecutiveErrors < MAX_POLL_RETRIES) { + // 일시적인 네트워크 오류일 수 있으니 바로 포기하지 않고 재시도 + setPollError(message); + timer = setTimeout(poll, POLL_INTERVAL_MS); + } else { + setPollError(message); + } + } + }; + + poll(); + return () => { + cancelled = true; + clearTimeout(timer); + }; + }, [caseId, isStopping, router, title]); + + const percent = status?.progress ?? 0; + const label = status?.step ?? "탐색 준비 중"; + const terminalMessage = + status && status.status !== "in_progress" && status.status !== "completed" + ? TERMINAL_MESSAGE[status.status]?.(status) + : null; + + return { + percent, + label, + terminalMessage, + pollError, + cancelError, + isStopping, + handleStop, + }; +} From c02bb4a44bf9f1d81a5a8325dd6222d3c616b0b9 Mon Sep 17 00:00:00 2001 From: girimNam Date: Thu, 30 Jul 2026 00:15:47 +0900 Subject: [PATCH 05/10] =?UTF-8?q?refactor:=20=ED=83=90=EC=83=89=EA=B2=B0?= =?UTF-8?q?=EA=B3=BC=20=ED=8E=98=EC=9D=B4=EC=A7=80=20api=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/(main)/search/result/page.tsx | 120 +++++-------------------- src/hooks/useSearchResult.ts | 121 ++++++++++++++++++++++++++ 2 files changed, 141 insertions(+), 100 deletions(-) create mode 100644 src/hooks/useSearchResult.ts diff --git a/src/app/(main)/search/result/page.tsx b/src/app/(main)/search/result/page.tsx index d72f21e..eb307f6 100644 --- a/src/app/(main)/search/result/page.tsx +++ b/src/app/(main)/search/result/page.tsx @@ -1,110 +1,32 @@ "use client"; import Link from "next/link"; -import { Suspense, useEffect, useState } from "react"; -import { useSearchParams } from "next/navigation"; +import { Suspense } from "react"; import { PatentImportModal } from "@/components/search/PatentImportModal"; import { ProjectList } from "@/components/searchlist/ProjectList"; import { ResultListHeader } from "@/components/searchlist/ResultListHeader"; import { SortingTag } from "@/components/searchlist/SortingTag"; import { BackButton } from "@/components/ui/BackButton"; import { Button } from "@/components/ui/Button"; -import { - addPriorArtsManual, - getPriorArts, - PRIOR_ARTS_ERROR_MESSAGES, - ADD_PRIOR_ARTS_MANUAL_ERROR_MESSAGES, -} from "@/lib/api/search"; -import { ApiError } from "@/lib/api/error"; import { RELEVANCE_LABEL, RELEVANCE_VARIANT } from "@/lib/priorArtRelevance"; -import { - PRIOR_ART_SORT_OPTIONS, - usePriorArtSort, - type PriorArtSortOption, -} from "@/hooks/usePriorArtSort"; -import type { PriorArt } from "@/types/search.type"; +import { PRIOR_ART_SORT_OPTIONS, type PriorArtSortOption } from "@/hooks/usePriorArtSort"; +import { useSearchResult } from "@/hooks/useSearchResult"; function SearchResultContent() { - const searchParams = useSearchParams(); - const caseId = searchParams.get("caseId"); - const title = searchParams.get("title"); - const techLinkQuery = title ? `?title=${encodeURIComponent(title)}` : ""; - - const [priorArts, setPriorArts] = useState([]); - const [totalCount, setTotalCount] = useState(0); - const [isLoading, setIsLoading] = useState(() => Boolean(caseId)); - const [error, setError] = useState(null); - const [isPatentImportModalOpen, setIsPatentImportModalOpen] = useState(false); - const [isImporting, setIsImporting] = useState(false); - const [importError, setImportError] = useState(null); - const { setSortOption, sorted: sortedPriorArts } = usePriorArtSort(priorArts); - - //선행기술 목록 불러오는 api - useEffect(() => { - if (!caseId) return; - - let cancelled = false; - - getPriorArts(Number(caseId)) - .then((result) => { - if (cancelled) return; - setPriorArts(result.priorArts); - setTotalCount(result.totalCount); - setError(null); - }) - .catch((err) => { - if (cancelled) return; - if (err instanceof ApiError) { - setError( - PRIOR_ARTS_ERROR_MESSAGES[err.errorCode] || - err.message || - "선행문헌 목록을 불러오는 중 오류가 발생했습니다." - ); - } else { - setError("선행문헌 목록을 불러오는 중 오류가 발생했습니다."); - } - }) - .finally(() => { - if (cancelled) return; - setIsLoading(false); - }); - - return () => { - cancelled = true; - }; - }, [caseId]); - - //특허번호로 불러오기 api - const handleImportPatentNumber = async ({ patentNumber }: { patentNumber: string }) => { - if (!caseId) return; - const trimmed = patentNumber.trim(); - if (!trimmed) { - setImportError("특허번호를 입력해주세요."); - return; - } - - setImportError(null); - setIsImporting(true); - - try { - const result = await addPriorArtsManual(Number(caseId), { applicationNumbers: [trimmed] }); - setPriorArts(result.priorArts); - setTotalCount(result.totalCount); - setIsPatentImportModalOpen(false); - } catch (err) { - if (err instanceof ApiError) { - setImportError( - ADD_PRIOR_ARTS_MANUAL_ERROR_MESSAGES[err.errorCode] || - err.message || - "선행문헌 추가 중 오류가 발생했습니다." - ); - } else { - setImportError("선행문헌 추가 중 오류가 발생했습니다."); - } - } finally { - setIsImporting(false); - } - }; + const { + techLinkQuery, + totalCount, + isLoading, + error, + sortedPriorArts, + setSortOption, + isPatentImportModalOpen, + openImportModal, + closeImportModal, + isImporting, + importError, + handleImportPatentNumber, + } = useSearchResult(); return (
@@ -123,14 +45,12 @@ function SearchResultContent() { options={[...PRIOR_ART_SORT_OPTIONS]} onChange={(value) => setSortOption(value as PriorArtSortOption)} /> + @@ -172,7 +92,7 @@ function SearchResultContent() { {isPatentImportModalOpen && ( setIsPatentImportModalOpen(false)} + onClose={closeImportModal} onSubmit={handleImportPatentNumber} error={importError} isSubmitting={isImporting} diff --git a/src/hooks/useSearchResult.ts b/src/hooks/useSearchResult.ts new file mode 100644 index 0000000..0e39e38 --- /dev/null +++ b/src/hooks/useSearchResult.ts @@ -0,0 +1,121 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useSearchParams } from "next/navigation"; +import { + addPriorArtsManual, + getPriorArts, + PRIOR_ARTS_ERROR_MESSAGES, + ADD_PRIOR_ARTS_MANUAL_ERROR_MESSAGES, +} from "@/lib/api/search"; +import { ApiError } from "@/lib/api/error"; +import { usePriorArtSort } from "@/hooks/usePriorArtSort"; +import type { PriorArt } from "@/types/search.type"; + +// 선행기술 탐색 결과 페이지: 선행문헌 목록 조회 + 출원번호로 수동 추가 +export function useSearchResult() { + const searchParams = useSearchParams(); + const caseId = searchParams.get("caseId"); + const title = searchParams.get("title"); + const techLinkQuery = title ? `?title=${encodeURIComponent(title)}` : ""; + + const [priorArts, setPriorArts] = useState([]); + const [totalCount, setTotalCount] = useState(0); + const [isLoading, setIsLoading] = useState(() => Boolean(caseId)); + const [error, setError] = useState(null); + const [isPatentImportModalOpen, setIsPatentImportModalOpen] = useState(false); + const [isImporting, setIsImporting] = useState(false); + const [importError, setImportError] = useState(null); + const { setSortOption, sorted: sortedPriorArts } = usePriorArtSort(priorArts); + + //선행기술 목록 불러오는 api + useEffect(() => { + if (!caseId) return; + + let cancelled = false; + + getPriorArts(Number(caseId)) + .then((result) => { + if (cancelled) return; + setPriorArts(result.priorArts); + setTotalCount(result.totalCount); + setError(null); + }) + .catch((err) => { + if (cancelled) return; + if (err instanceof ApiError) { + setError( + PRIOR_ARTS_ERROR_MESSAGES[err.errorCode] || + err.message || + "선행문헌 목록을 불러오는 중 오류가 발생했습니다." + ); + } else { + setError("선행문헌 목록을 불러오는 중 오류가 발생했습니다."); + } + }) + .finally(() => { + if (cancelled) return; + setIsLoading(false); + }); + + return () => { + cancelled = true; + }; + }, [caseId]); + + const openImportModal = () => { + setImportError(null); + setIsPatentImportModalOpen(true); + }; + + const closeImportModal = () => { + setIsPatentImportModalOpen(false); + }; + + //특허번호로 불러오기 api + const handleImportPatentNumber = async ({ patentNumber }: { patentNumber: string }) => { + if (!caseId) return; + const trimmed = patentNumber.trim(); + if (!trimmed) { + setImportError("특허번호를 입력해주세요."); + return; + } + + setImportError(null); + setIsImporting(true); + + try { + const result = await addPriorArtsManual(Number(caseId), { applicationNumbers: [trimmed] }); + setPriorArts(result.priorArts); + setTotalCount(result.totalCount); + setIsPatentImportModalOpen(false); + } catch (err) { + if (err instanceof ApiError) { + setImportError( + ADD_PRIOR_ARTS_MANUAL_ERROR_MESSAGES[err.errorCode] || + err.message || + "선행문헌 추가 중 오류가 발생했습니다." + ); + } else { + setImportError("선행문헌 추가 중 오류가 발생했습니다."); + } + } finally { + setIsImporting(false); + } + }; + + return { + techLinkQuery, + totalCount, + isLoading, + error, + sortedPriorArts, + setSortOption, + isPatentImportModalOpen, + openImportModal, + closeImportModal, + isImporting, + importError, + handleImportPatentNumber, + }; +} From 8fcdb5b08827191ec558fda9636f5cc99febdbef Mon Sep 17 00:00:00 2001 From: girimNam Date: Thu, 30 Jul 2026 00:16:09 +0900 Subject: [PATCH 06/10] =?UTF-8?q?fix:=20=ED=83=9C=EA=B7=B8=EC=B9=A9-?= =?UTF-8?q?=EB=B2=84=ED=8A=BC=20=EC=95=88=EA=B2=B9=EC=B9=98=EA=B2=8C+?= =?UTF-8?q?=EA=B7=B8=EB=9D=BC=EB=8D=B0=EC=9D=B4=EC=85=98=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/searchlist/ProjectList.tsx | 34 +++++++++++++++++------ 1 file changed, 25 insertions(+), 9 deletions(-) diff --git a/src/components/searchlist/ProjectList.tsx b/src/components/searchlist/ProjectList.tsx index dccb251..5c7c41a 100644 --- a/src/components/searchlist/ProjectList.tsx +++ b/src/components/searchlist/ProjectList.tsx @@ -149,17 +149,33 @@ export function ProjectList({
{canScrollMore && ( - +
+ + )}
From d439d99f78f5f01c071b0ee0d71425da91a878a1 Mon Sep 17 00:00:00 2001 From: girimNam Date: Thu, 30 Jul 2026 00:19:23 +0900 Subject: [PATCH 07/10] =?UTF-8?q?refactor:=20=EA=B0=9C=EB=B3=84=20?= =?UTF-8?q?=ED=8A=B9=ED=97=88=20=EC=83=81=EC=84=B8=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20api=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/(main)/tech/[id]/page.tsx | 61 +--------------------------- src/hooks/useTechDetail.ts | 67 +++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 59 deletions(-) create mode 100644 src/hooks/useTechDetail.ts diff --git a/src/app/(main)/tech/[id]/page.tsx b/src/app/(main)/tech/[id]/page.tsx index 7d5416a..02d11cf 100644 --- a/src/app/(main)/tech/[id]/page.tsx +++ b/src/app/(main)/tech/[id]/page.tsx @@ -1,7 +1,5 @@ "use client"; -import { useEffect, useState } from "react"; -import { useParams } from "next/navigation"; import ExternalIcon from "@/components/icons/icon-external.svg"; import { ExplainSection } from "@/components/analysis/AnalysisRightPanel/ExplainSection"; import { InfoCard } from "@/components/analysis/AnalysisRightPanel/InfoCard"; @@ -10,68 +8,13 @@ import { StatusBadge } from "@/components/searchlist/StatusBadge"; import { TagChip } from "@/components/searchlist/TagChip"; import { BackButton } from "@/components/ui/BackButton"; import { Button } from "@/components/ui/Button"; -import { getPriorArtDetail } from "@/lib/api/search"; -import { ApiError } from "@/lib/api/error"; -import { useKiprisThumbnail } from "@/hooks/useKiprisThumbnail"; import { formatPeriod } from "@/lib/priorArtFormat"; import { buildOriginalDocumentUrl } from "@/lib/patentOriginalDocument"; import { RELEVANCE_LABEL, RELEVANCE_VARIANT } from "@/lib/priorArtRelevance"; -import type { PriorArtDetail } from "@/types/search.type"; - -// 선행문헌 상세 조회 api 에러코드별 메시지 -const PRIOR_ART_DETAIL_ERROR_MESSAGES: Record = { - SC001: "인증이 필요합니다.", - CA002: "해당 사건에 접근할 권한이 없습니다.", - P001: "해당 선행기술을 찾을 수 없습니다.", - P003: "선행기술 문서를 찾을 수 없습니다.", - O001: "특허 검색 서버와 통신 중 오류가 발생했습니다.", - C002: "서버 내부 오류가 발생했습니다.", -}; +import { useTechDetail } from "@/hooks/useTechDetail"; export default function TechDetailPage() { - const params = useParams<{ id: string }>(); - const priorArtId = params.id; - - const [detail, setDetail] = useState(null); - const [isLoading, setIsLoading] = useState(() => Boolean(priorArtId)); - const [error, setError] = useState(null); - const thumbnailUrl = useKiprisThumbnail(detail?.applicationNumber); - - //선행문헌 상세 조회 - useEffect(() => { - if (!priorArtId) return; - - let cancelled = false; - - getPriorArtDetail(Number(priorArtId)) - .then((result) => { - if (cancelled) return; - - setDetail(result); - setError(null); - }) - .catch((err) => { - if (cancelled) return; - - if (err instanceof ApiError) { - setError( - PRIOR_ART_DETAIL_ERROR_MESSAGES[err.errorCode] || - err.message || - "선행문헌 상세를 불러오는 중 오류가 발생했습니다." - ); - } else { - setError("선행문헌 상세를 불러오는 중 오류가 발생했습니다."); - } - }) - .finally(() => { - if (cancelled) return; - setIsLoading(false); - }); - - return () => { - cancelled = true; - }; - }, [priorArtId]); + const { priorArtId, detail, isLoading, error, thumbnailUrl } = useTechDetail(); if (isLoading) { return ( diff --git a/src/hooks/useTechDetail.ts b/src/hooks/useTechDetail.ts new file mode 100644 index 0000000..b9b2f60 --- /dev/null +++ b/src/hooks/useTechDetail.ts @@ -0,0 +1,67 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useParams } from "next/navigation"; +import { getPriorArtDetail } from "@/lib/api/search"; +import { ApiError } from "@/lib/api/error"; +import { useKiprisThumbnail } from "@/hooks/useKiprisThumbnail"; +import type { PriorArtDetail } from "@/types/search.type"; + +// 선행문헌 상세 조회 api 에러코드별 메시지 +const PRIOR_ART_DETAIL_ERROR_MESSAGES: Record = { + SC001: "인증이 필요합니다.", + CA002: "해당 사건에 접근할 권한이 없습니다.", + P001: "해당 선행기술을 찾을 수 없습니다.", + P003: "선행기술 문서를 찾을 수 없습니다.", + O001: "특허 검색 서버와 통신 중 오류가 발생했습니다.", + C002: "서버 내부 오류가 발생했습니다.", +}; + +// 선행기술 상세 페이지: 선행문헌 상세 조회 +export function useTechDetail() { + const params = useParams<{ id: string }>(); + const priorArtId = params.id; + + const [detail, setDetail] = useState(null); + const [isLoading, setIsLoading] = useState(() => Boolean(priorArtId)); + const [error, setError] = useState(null); + const thumbnailUrl = useKiprisThumbnail(detail?.applicationNumber); + + //선행문헌 상세 조회 + useEffect(() => { + if (!priorArtId) return; + + let cancelled = false; + + getPriorArtDetail(Number(priorArtId)) + .then((result) => { + if (cancelled) return; + + setDetail(result); + setError(null); + }) + .catch((err) => { + if (cancelled) return; + + if (err instanceof ApiError) { + setError( + PRIOR_ART_DETAIL_ERROR_MESSAGES[err.errorCode] || + err.message || + "선행문헌 상세를 불러오는 중 오류가 발생했습니다." + ); + } else { + setError("선행문헌 상세를 불러오는 중 오류가 발생했습니다."); + } + }) + .finally(() => { + if (cancelled) return; + setIsLoading(false); + }); + + return () => { + cancelled = true; + }; + }, [priorArtId]); + + return { priorArtId, detail, isLoading, error, thumbnailUrl }; +} From 44c89560afe89d238f35213706fd15bbe3f50d26 Mon Sep 17 00:00:00 2001 From: girimNam Date: Thu, 30 Jul 2026 00:21:29 +0900 Subject: [PATCH 08/10] =?UTF-8?q?refactor:=20=EB=A6=AC=ED=8F=AC=ED=8A=B8?= =?UTF-8?q?=20=ED=8E=98=EC=9D=B4=EC=A7=80=20api=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../(report)/myhistory/[id]/report/page.tsx | 74 ++--------------- src/hooks/useReport.ts | 80 +++++++++++++++++++ 2 files changed, 86 insertions(+), 68 deletions(-) create mode 100644 src/hooks/useReport.ts diff --git a/src/app/(report)/myhistory/[id]/report/page.tsx b/src/app/(report)/myhistory/[id]/report/page.tsx index e9e8348..e7a4a78 100644 --- a/src/app/(report)/myhistory/[id]/report/page.tsx +++ b/src/app/(report)/myhistory/[id]/report/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { use, useEffect, useState } from "react"; +import { use } from "react"; import { BackButton } from "@/components/ui/BackButton"; import { Button } from "@/components/ui/Button"; import { PrintButton } from "@/components/ui/PrintButton"; @@ -9,23 +9,13 @@ import ReportOverview from "@/components/report/Overview"; import NoveltyComparison from "@/components/report/NoveltyComparision"; import InventiveStep from "@/components/report/InventiveStep"; import TotalConclusion from "@/components/report/Conclusion"; -import { getReport } from "@/lib/api/analysis"; -import { ApiError } from "@/lib/api/error"; -import { useAuthStore } from "@/store/authStore"; +import { useReport } from "@/hooks/useReport"; import type { ReportDetailResponse, ReportInventiveArgumentType, ReportPriorArt, } from "@/types/report.type"; -const REPORT_ERROR_MESSAGES: Record = { - SC001: "로그인이 필요합니다.", - CA002: "해당 사건에 접근할 권한이 없습니다.", - CA001: "사건을 찾을 수 없습니다.", - RP002: "분석 리포트를 찾을 수 없습니다.", - C002: "서버 내부 오류가 발생했습니다. 잠시 후 다시 시도해주세요.", -}; - type EffectItem = { metric?: unknown; unit?: unknown; @@ -69,53 +59,9 @@ function getArgument(report: ReportDetailResponse, argumentType: ReportInventive export default function ReportPage({ params }: { params: Promise<{ id: string }> }) { const { id } = use(params); - const isAuthInitialized = useAuthStore((state) => state.isInitialized); - const accessToken = useAuthStore((state) => state.accessToken); - const [result, setResult] = useState<{ caseId: string; report: ReportDetailResponse } | null>( - null - ); - const [requestError, setRequestError] = useState<{ caseId: string; message: string } | null>( - null - ); - const [reloadCount, setReloadCount] = useState(0); - - useEffect(() => { - if (!isAuthInitialized || !accessToken) return; - - let canceled = false; - - getReport(id) - .then((data) => { - if (canceled) return; - setRequestError(null); - setResult({ caseId: id, report: data }); - }) - .catch((error) => { - if (canceled) return; - setResult(null); - setRequestError({ - caseId: id, - message: - error instanceof ApiError - ? (REPORT_ERROR_MESSAGES[error.errorCode] ?? error.message) - : "분석 리포트를 불러오는 중 네트워크 오류가 발생했습니다.", - }); - }); - - return () => { - canceled = true; - }; - }, [accessToken, id, isAuthInitialized, reloadCount]); - - const report = result?.caseId === id ? result.report : null; - const errorMessage = - !isAuthInitialized || accessToken - ? requestError?.caseId === id - ? requestError.message - : null - : REPORT_ERROR_MESSAGES.SC001; - - if (!isAuthInitialized || (accessToken && !report && !errorMessage)) { + const { accessToken, report, errorMessage, isLoading, reload } = useReport(id); + + if (isLoading) { return (

분석 리포트를 불러오고 있습니다...

@@ -130,15 +76,7 @@ export default function ReportPage({ params }: { params: Promise<{ id: string }> {errorMessage ?? "분석 리포트를 불러오지 못했습니다."}

{accessToken && ( - )} diff --git a/src/hooks/useReport.ts b/src/hooks/useReport.ts new file mode 100644 index 0000000..a3a7ea4 --- /dev/null +++ b/src/hooks/useReport.ts @@ -0,0 +1,80 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { getReport } from "@/lib/api/analysis"; +import { ApiError } from "@/lib/api/error"; +import { useAuthStore } from "@/store/authStore"; +import type { ReportDetailResponse } from "@/types/report.type"; + +const REPORT_ERROR_MESSAGES: Record = { + SC001: "로그인이 필요합니다.", + CA002: "해당 사건에 접근할 권한이 없습니다.", + CA001: "사건을 찾을 수 없습니다.", + RP002: "분석 리포트를 찾을 수 없습니다.", + C002: "서버 내부 오류가 발생했습니다. 잠시 후 다시 시도해주세요.", +}; + +// 분석 리포트 페이지: 리포트 조회 +export function useReport(id: string) { + const isAuthInitialized = useAuthStore((state) => state.isInitialized); + const accessToken = useAuthStore((state) => state.accessToken); + + const [result, setResult] = useState<{ caseId: string; report: ReportDetailResponse } | null>( + null + ); + const [requestError, setRequestError] = useState<{ caseId: string; message: string } | null>( + null + ); + const [reloadCount, setReloadCount] = useState(0); + + useEffect(() => { + if (!isAuthInitialized || !accessToken) return; + + let canceled = false; + + getReport(id) + .then((data) => { + if (canceled) return; + setRequestError(null); + setResult({ caseId: id, report: data }); + }) + .catch((error) => { + if (canceled) return; + setResult(null); + setRequestError({ + caseId: id, + message: + error instanceof ApiError + ? (REPORT_ERROR_MESSAGES[error.errorCode] ?? error.message) + : "분석 리포트를 불러오는 중 네트워크 오류가 발생했습니다.", + }); + }); + + return () => { + canceled = true; + }; + }, [accessToken, id, isAuthInitialized, reloadCount]); + + const report = result?.caseId === id ? result.report : null; + const errorMessage = + !isAuthInitialized || accessToken + ? requestError?.caseId === id + ? requestError.message + : null + : REPORT_ERROR_MESSAGES.SC001; + const isLoading = !isAuthInitialized || Boolean(accessToken && !report && !errorMessage); + + const reload = () => { + setResult(null); + setRequestError(null); + setReloadCount((count) => count + 1); + }; + + return { + accessToken, + report, + errorMessage, + isLoading, + reload, + }; +} From a9d42adf0397aa0cddcfcd39f40d08323e45dd50 Mon Sep 17 00:00:00 2001 From: girimNam Date: Thu, 30 Jul 2026 00:42:52 +0900 Subject: [PATCH 09/10] =?UTF-8?q?fix:=20useSearchProgress=20=ED=9B=85?= =?UTF-8?q?=EC=97=90=20activeSearchStore=20=EC=97=B0=EB=8F=99=20=EB=B0=98?= =?UTF-8?q?=EC=98=81=20=EB=88=84=EB=9D=BD=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 병합 커밋 시 useSearchProgress.ts의 activeSearchStore/resetSearchForm 연동 변경사항이 스테이징되지 않아 이전 버전으로 커밋된 것을 바로잡음. --- src/hooks/useSearchProgress.ts | 44 ++++++++++++++++++++++++++++++---- 1 file changed, 40 insertions(+), 4 deletions(-) diff --git a/src/hooks/useSearchProgress.ts b/src/hooks/useSearchProgress.ts index 0a43f81..f55b3c3 100644 --- a/src/hooks/useSearchProgress.ts +++ b/src/hooks/useSearchProgress.ts @@ -4,8 +4,12 @@ import { useEffect, useState } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import { cancelSearch, getSearchStatus } from "@/lib/api/search"; import { ApiError } from "@/lib/api/error"; +import { useActiveSearchStore } from "@/store/activeSearchStore"; +import { useAuthStore } from "@/store/authStore"; +import { useSearchFormStore } from "@/store/searchFormStore"; import type { SearchStatusResponse } from "@/types/search.type"; +const DEFAULT_RESULT_COUNT = 10; const POLL_INTERVAL_MS = 2000; const MAX_POLL_RETRIES = 5; @@ -30,11 +34,21 @@ const TERMINAL_MESSAGE: Record string> }; // 선행기술 탐색 로딩 페이지: 진행 상태 폴링 + 중단하기 +// activeSearchStore에 진행 중인 검색을 저장해두어, URL 쿼리 없이 이 페이지로 돌아와도 진행 상황을 복구할 수 있게 한다. export function useSearchProgress() { const router = useRouter(); const searchParams = useSearchParams(); - const caseId = searchParams.get("caseId"); - const title = searchParams.get("title"); + const activeSearch = useActiveSearchStore((state) => state.activeSearch); + const setActiveSearch = useActiveSearchStore((state) => state.setActiveSearch); + const clearActiveSearch = useActiveSearchStore((state) => state.clearActiveSearch); + const resetSearchForm = useSearchFormStore((state) => state.resetForm); + const isAuthInitialized = useAuthStore((state) => state.isInitialized); + const accessToken = useAuthStore((state) => state.accessToken); + + const caseId = searchParams.get("caseId") ?? activeSearch?.caseId.toString() ?? null; + const title = searchParams.get("title") ?? activeSearch?.title ?? null; + const resultCount = + Number(searchParams.get("count") ?? activeSearch?.resultCount) || DEFAULT_RESULT_COUNT; const [status, setStatus] = useState(null); const [pollError, setPollError] = useState(null); @@ -51,6 +65,7 @@ export function useSearchProgress() { setIsStopping(true); try { await cancelSearch(Number(caseId)); + clearActiveSearch(); router.push("/search?resume=1"); } catch (err) { if (err instanceof ApiError) { @@ -72,7 +87,13 @@ export function useSearchProgress() { router.push("/search?resume=1"); return; } - if (isStopping) return; + if (isStopping || !isAuthInitialized || !accessToken) return; + + setActiveSearch({ + caseId: Number(caseId), + resultCount, + title: title ?? "", + }); let cancelled = false; let timer: ReturnType; @@ -89,9 +110,13 @@ export function useSearchProgress() { if (result.status === "in_progress") { timer = setTimeout(poll, POLL_INTERVAL_MS); } else if (result.status === "completed") { + clearActiveSearch(); + resetSearchForm(); router.push( `/search/result?caseId=${caseId}${title ? `&title=${encodeURIComponent(title)}` : ""}` ); + } else { + clearActiveSearch(); } } catch (err) { if (cancelled) return; @@ -119,7 +144,18 @@ export function useSearchProgress() { cancelled = true; clearTimeout(timer); }; - }, [caseId, isStopping, router, title]); + }, [ + accessToken, + caseId, + clearActiveSearch, + isAuthInitialized, + isStopping, + resultCount, + resetSearchForm, + router, + setActiveSearch, + title, + ]); const percent = status?.progress ?? 0; const label = status?.step ?? "탐색 준비 중"; From 2831803b383405330f643be60d59cb8517f83676 Mon Sep 17 00:00:00 2001 From: girimNam Date: Thu, 30 Jul 2026 01:00:49 +0900 Subject: [PATCH 10/10] =?UTF-8?q?chore:=20=EC=BD=94=EB=93=9C=EB=9E=98?= =?UTF-8?q?=EB=B9=97=20=EB=A6=AC=EB=B7=B0=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../(report)/myhistory/[id]/report/page.tsx | 4 +- src/hooks/useAnalysisPriorArts.ts | 43 ++++++++++-------- src/hooks/useReport.ts | 2 +- src/hooks/useSearchResult.ts | 2 +- src/hooks/useTechDetail.ts | 45 ++++++++++--------- 5 files changed, 52 insertions(+), 44 deletions(-) diff --git a/src/app/(report)/myhistory/[id]/report/page.tsx b/src/app/(report)/myhistory/[id]/report/page.tsx index e7a4a78..b18a6c1 100644 --- a/src/app/(report)/myhistory/[id]/report/page.tsx +++ b/src/app/(report)/myhistory/[id]/report/page.tsx @@ -59,7 +59,7 @@ function getArgument(report: ReportDetailResponse, argumentType: ReportInventive export default function ReportPage({ params }: { params: Promise<{ id: string }> }) { const { id } = use(params); - const { accessToken, report, errorMessage, isLoading, reload } = useReport(id); + const { isAuthenticated, report, errorMessage, isLoading, reload } = useReport(id); if (isLoading) { return ( @@ -75,7 +75,7 @@ export default function ReportPage({ params }: { params: Promise<{ id: string }>

{errorMessage ?? "분석 리포트를 불러오지 못했습니다."}

- {accessToken && ( + {isAuthenticated && ( diff --git a/src/hooks/useAnalysisPriorArts.ts b/src/hooks/useAnalysisPriorArts.ts index 049aa78..af7c1e3 100644 --- a/src/hooks/useAnalysisPriorArts.ts +++ b/src/hooks/useAnalysisPriorArts.ts @@ -22,9 +22,8 @@ export function useAnalysisPriorArts(id: string) { const selectedPatent = useAnalysisStore((state) => state.selectedPatent); const setSelectedPatent = useAnalysisStore((state) => state.setSelectedPatent); - const [priorArts, setPriorArts] = useState([]); - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(null); + const [result, setResult] = useState<{ id: string; priorArts: PriorArt[] } | null>(null); + const [requestError, setRequestError] = useState<{ id: string; message: string } | null>(null); const [selectingId, setSelectingId] = useState(null); const [selectError, setSelectError] = useState(null); @@ -57,26 +56,23 @@ export function useAnalysisPriorArts(id: string) { let cancelled = false; getPriorArts(caseId) - .then((result) => { + .then((data) => { if (cancelled) return; - setPriorArts(result.priorArts); - setError(null); + setRequestError(null); + setResult({ id, priorArts: data.priorArts }); }) .catch((err) => { if (cancelled) return; - if (err instanceof ApiError) { - setError( - PRIOR_ARTS_ERROR_MESSAGES[err.errorCode] || - err.message || - "선행문헌 목록을 불러오는 중 오류가 발생했습니다." - ); - } else { - setError("선행문헌 목록을 불러오는 중 오류가 발생했습니다."); - } - }) - .finally(() => { - if (cancelled) return; - setIsLoading(false); + setResult(null); + setRequestError({ + id, + message: + err instanceof ApiError + ? (PRIOR_ARTS_ERROR_MESSAGES[err.errorCode] ?? + err.message ?? + "선행문헌 목록을 불러오는 중 오류가 발생했습니다.") + : "선행문헌 목록을 불러오는 중 오류가 발생했습니다.", + }); }); return () => { @@ -84,6 +80,15 @@ export function useAnalysisPriorArts(id: string) { }; }, [id]); + const isValidId = Boolean(id) && !Number.isNaN(Number(id)); + const priorArts = result?.id === id ? result.priorArts : []; + const error = !isValidId + ? "잘못된 사건 번호입니다." + : requestError?.id === id + ? requestError.message + : null; + const isLoading = isValidId && result?.id !== id && !error; + //주인용으로 선택 -> 선행문헌 상세 조회 후 우측 패널에 표시 const handleSelect = async (priorArt: PriorArt) => { setSelectError(null); diff --git a/src/hooks/useReport.ts b/src/hooks/useReport.ts index a3a7ea4..1ab2433 100644 --- a/src/hooks/useReport.ts +++ b/src/hooks/useReport.ts @@ -71,7 +71,7 @@ export function useReport(id: string) { }; return { - accessToken, + isAuthenticated: Boolean(accessToken), report, errorMessage, isLoading, diff --git a/src/hooks/useSearchResult.ts b/src/hooks/useSearchResult.ts index 0e39e38..5bb158a 100644 --- a/src/hooks/useSearchResult.ts +++ b/src/hooks/useSearchResult.ts @@ -108,7 +108,7 @@ export function useSearchResult() { techLinkQuery, totalCount, isLoading, - error, + error: caseId ? error : "사건 정보를 찾을 수 없습니다. 다시 탐색을 시작해주세요.", sortedPriorArts, setSortOption, isPatentImportModalOpen, diff --git a/src/hooks/useTechDetail.ts b/src/hooks/useTechDetail.ts index b9b2f60..51da776 100644 --- a/src/hooks/useTechDetail.ts +++ b/src/hooks/useTechDetail.ts @@ -22,10 +22,12 @@ export function useTechDetail() { const params = useParams<{ id: string }>(); const priorArtId = params.id; - const [detail, setDetail] = useState(null); - const [isLoading, setIsLoading] = useState(() => Boolean(priorArtId)); - const [error, setError] = useState(null); - const thumbnailUrl = useKiprisThumbnail(detail?.applicationNumber); + // 조회 결과를 요청 당시의 priorArtId와 함께 저장해서, id가 바뀌면 파생값(detail/error/isLoading)이 + // 자동으로 "로딩 중" 상태로 돌아가도록 한다 (같은 페이지에서 id만 바뀌는 네비게이션에서 이전 상세가 잠깐 보이는 것 방지). + const [result, setResult] = useState<{ priorArtId: string; detail: PriorArtDetail } | null>(null); + const [requestError, setRequestError] = useState<{ priorArtId: string; message: string } | null>( + null + ); //선행문헌 상세 조회 useEffect(() => { @@ -34,28 +36,24 @@ export function useTechDetail() { let cancelled = false; getPriorArtDetail(Number(priorArtId)) - .then((result) => { + .then((data) => { if (cancelled) return; - - setDetail(result); - setError(null); + setRequestError(null); + setResult({ priorArtId, detail: data }); }) .catch((err) => { if (cancelled) return; - if (err instanceof ApiError) { - setError( - PRIOR_ART_DETAIL_ERROR_MESSAGES[err.errorCode] || - err.message || - "선행문헌 상세를 불러오는 중 오류가 발생했습니다." - ); - } else { - setError("선행문헌 상세를 불러오는 중 오류가 발생했습니다."); - } - }) - .finally(() => { - if (cancelled) return; - setIsLoading(false); + setResult(null); + setRequestError({ + priorArtId, + message: + err instanceof ApiError + ? (PRIOR_ART_DETAIL_ERROR_MESSAGES[err.errorCode] ?? + err.message ?? + "선행문헌 상세를 불러오는 중 오류가 발생했습니다.") + : "선행문헌 상세를 불러오는 중 오류가 발생했습니다.", + }); }); return () => { @@ -63,5 +61,10 @@ export function useTechDetail() { }; }, [priorArtId]); + const detail = result?.priorArtId === priorArtId ? result.detail : null; + const error = requestError?.priorArtId === priorArtId ? requestError.message : null; + const isLoading = Boolean(priorArtId) && !detail && !error; + const thumbnailUrl = useKiprisThumbnail(detail?.applicationNumber); + return { priorArtId, detail, isLoading, error, thumbnailUrl }; }