Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 4 additions & 118 deletions src/app/(main)/analysis/(workspace)/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
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<PriorArt[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectingId, setSelectingId] = useState<number | null>(null);
const [selectError, setSelectError] = useState<string | null>(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 (
<div data-analysis-id={id} className="flex w-full flex-col items-start gap-4 self-stretch">
Expand Down
226 changes: 18 additions & 208 deletions src/app/(main)/analysis/[id]/[patentId]/page.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -13,51 +13,12 @@ import {
type InventiveStepLogicKey,
} from "@/constants/analysis/inventiveStep";
import { Button } from "@/components/ui/Button";
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<string, string> = {
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,
Expand Down Expand Up @@ -136,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<Set<InventiveStepLogicKey>>(new Set());
// AI가 최초 분석 시 추천한 논리 카테고리(argumentId) 고정 스냅샷 -> 이후 사용자가 선택/해제해도 "AI 추천" 배지는 바뀌면 안 됨
const [aiRecommendedArgumentIds, setAiRecommendedArgumentIds] = useState<Set<number>>(new Set());
const [placeholderArgumentIds, setPlaceholderArgumentIds] = useState<Set<number>>(new Set());
const [updatingLogics, setUpdatingLogics] = useState<Set<InventiveStepLogicKey>>(new Set());
const [updateError, setUpdateError] = useState<string | null>(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 (
<div className="flex min-h-80 items-center justify-center">
<p className="text-body-15 text-caption-label">진보성 분석 결과를 불러오고 있습니다...</p>
Expand All @@ -305,17 +125,7 @@ export default function AnalysisReportPage({
<p role="alert" className="text-body-17 text-body-secondary">
{errorMessage ?? "진보성 분석 결과를 불러오지 못했습니다."}
</p>
<Button
variant="secondary"
size="sm"
onClick={() => {
setResult(null);
setRequestError(null);
setSelectedLogics(new Set());
setPlaceholderArgumentIds(new Set());
setReloadCount((count) => count + 1);
}}
>
<Button variant="secondary" size="sm" onClick={reload}>
다시 조회
</Button>
</div>
Expand Down
Loading
Loading