+
{variant === "single" ? (
+ ) : isCentered ? (
+
+
+
+
) : (
<>
diff --git a/jobdri/src/components/mockApply/ResumeAnalysisLoading.tsx b/jobdri/src/components/mockApply/ResumeAnalysisLoading.tsx
index 9b2c514..ecfa710 100644
--- a/jobdri/src/components/mockApply/ResumeAnalysisLoading.tsx
+++ b/jobdri/src/components/mockApply/ResumeAnalysisLoading.tsx
@@ -7,7 +7,7 @@ import ProgressPanelRow from "@/components/common/progress/ProgressPanelRow";
import { TextButton } from "@/components/common/buttons";
import resumeAnalysisLoading from "@/assets/lottie/resume-analysis-loading.json";
import LoadingGraphic from "./LoadingGraphic";
-import { ModalCard } from "@/components/common/modal/ModalCard";
+import { ModalNotice } from "@/components/common/modal";
interface ResumeAnalysisLoadingProps {
durationMs: number;
@@ -157,12 +157,16 @@ export default function ResumeAnalysisLoading({
{isFailed && (
-
-
+ router.replace("/")}
+ onClose={() => router.replace("/")}
+ primaryAction={{
+ label: "확인",
+ onClick: () => router.replace("/"),
+ }}
/>
)}
diff --git a/jobdri/src/components/mockApply/home/ResultApplicationCard.tsx b/jobdri/src/components/mockApply/home/ResultApplicationCard.tsx
index deeacfd..0b44380 100644
--- a/jobdri/src/components/mockApply/home/ResultApplicationCard.tsx
+++ b/jobdri/src/components/mockApply/home/ResultApplicationCard.tsx
@@ -14,11 +14,13 @@ export function ResultApplicationCard({
createdAt,
score,
version = 1,
+ isRetryDisabled = false,
onDeleteClick,
onRetryClick,
onResumeClick,
}: ApplicationCardData & {
companyVariant?: "default" | "none";
+ isRetryDisabled?: boolean;
onDeleteClick: () => void;
onRetryClick?: () => void;
onResumeClick?: () => void;
@@ -52,6 +54,8 @@ export function ResultApplicationCard({
const handleRetry = (e: React.MouseEvent) => {
e.stopPropagation();
+ if (isRetryDisabled) return;
+
setIsMenuOpen(false);
onRetryClick?.(); // 부모로부터 받은 다시하기 함수 실행
};
@@ -92,7 +96,11 @@ export function ResultApplicationCard({
diff --git a/jobdri/src/components/mockApply/home/ResultApplicationList.tsx b/jobdri/src/components/mockApply/home/ResultApplicationList.tsx
index 0952f2a..1c3f1d4 100644
--- a/jobdri/src/components/mockApply/home/ResultApplicationList.tsx
+++ b/jobdri/src/components/mockApply/home/ResultApplicationList.tsx
@@ -7,6 +7,7 @@ import type { ApplicationCardData } from "./types";
interface ResultApplicationListProps {
applications: ApplicationCardData[];
+ isRetrying?: boolean;
onDelete: (application: ApplicationCardData) => void;
onRetry: (application: ApplicationCardData) => void;
onResume: (application: ApplicationCardData) => void;
@@ -14,6 +15,7 @@ interface ResultApplicationListProps {
export default function ResultApplicationList({
applications,
+ isRetrying = false,
onDelete,
onRetry,
onResume,
@@ -51,6 +53,7 @@ export default function ResultApplicationList({
onDelete(application)}
onRetryClick={() => onRetry(application)}
onResumeClick={() => onResume(application)}
diff --git a/jobdri/src/components/mockApply/home/applicationHomeUtils.ts b/jobdri/src/components/mockApply/home/applicationHomeUtils.ts
index 74c81e7..cc780ae 100644
--- a/jobdri/src/components/mockApply/home/applicationHomeUtils.ts
+++ b/jobdri/src/components/mockApply/home/applicationHomeUtils.ts
@@ -81,7 +81,7 @@ export function mapMockApplyToApplication(
jobPostingId: item.jobPostingId,
company: companyName || "회사명 미입력",
hasCompanyName: companyName.length > 0,
- profileColor: item.profileColor ?? "DEFAULT",
+ profileColor: "DEFAULT",
position: item.jobTitle || item.detailClassificationName || "직무 미분류",
createdAt: formatCreatedAt(item.createdAt),
createdAtTime: getCreatedAtTime(item.createdAt),
@@ -90,7 +90,7 @@ export function mapMockApplyToApplication(
resumePath: item.resumePath,
status,
applyType: item.applyType,
- version: item.version ?? 1,
+ version: item.sequence ?? 1,
};
}
diff --git a/jobdri/src/hooks/useReApply.ts b/jobdri/src/hooks/useReApply.ts
index 10ba9ff..c94eac4 100644
--- a/jobdri/src/hooks/useReApply.ts
+++ b/jobdri/src/hooks/useReApply.ts
@@ -1,6 +1,6 @@
"use client";
-import { useState } from "react";
+import { useRef, useState } from "react";
import { useRouter } from "next/navigation";
import {
type MockApplyRetryResult,
@@ -16,9 +16,12 @@ interface ReApplyOptions {
export function useReApply() {
const router = useRouter();
const [isSaving, setIsSaving] = useState(false);
+ const isSavingRef = useRef(false);
const reApply = async (mockApplyId: number, options?: ReApplyOptions) => {
- if (isSaving || !mockApplyId) return null;
+ if (isSavingRef.current || !mockApplyId) return null;
+
+ isSavingRef.current = true;
setIsSaving(true);
try {
@@ -31,10 +34,11 @@ export function useReApply() {
});
router.push(
options?.getRedirectPath?.(result) ??
- `/mockApply/${result.mockApplyId}?retry=1&sequence=${result.sequence}`,
+ `/mockApply/${result.mockApplyId}?sequence=${result.sequence}`,
);
return result;
} catch {
+ isSavingRef.current = false;
setIsSaving(false);
return null;
}
diff --git a/jobdri/src/lib/api/mockApplies.ts b/jobdri/src/lib/api/mockApplies.ts
index 8edd3c7..433f98a 100644
--- a/jobdri/src/lib/api/mockApplies.ts
+++ b/jobdri/src/lib/api/mockApplies.ts
@@ -2,8 +2,9 @@ import {
API_BASE_URL,
getAuthHeaders,
parseApiResponse,
+ parseApiResponseAllowNull,
} from "@/lib/api/client";
-import type { JobPostingProfileColor } from "@/lib/api/jobPostings";
+import type { SavedJobPosting } from "@/lib/api/jobPostings";
export type JobPostingApplyType = "MOCK" | "ACTUAL";
export type MockApplyProgressStatus =
@@ -16,6 +17,12 @@ export interface MockApplyFromJobPosting {
jobPostingId: number;
mockApplyId: number;
applyType: JobPostingApplyType;
+ sequence: number;
+}
+
+export interface MockApplyFromJobPostingPayload {
+ jobPostingId: number;
+ sequence?: number;
}
export interface MockApplyRetryResult {
@@ -36,18 +43,16 @@ export interface MockApplyResumeRecord {
export interface MockApplyHomeItem {
mockApplyId: number;
- resumePath?: string | null;
+ resumePath: string;
jobPostingId: number;
+ sequence: number;
status: MockApplyProgressStatus | string;
companyName: string;
- profileColor?: JobPostingProfileColor | null;
- postingName?: string | null;
- detailClassificationName?: string | null;
- jobTitle?: string | null;
+ detailClassificationName: string;
+ jobTitle: string;
createdAt: string;
applyType: JobPostingApplyType;
- score?: number | null;
- version: number;
+ score: number;
}
export interface MockApplyHomeList {
@@ -56,11 +61,23 @@ export interface MockApplyHomeList {
}
export const APPLY_TYPE_STORAGE_KEY = "jobdri.applyType";
+export const MOCK_APPLY_DELETED_EVENT = "jobdri:mock-apply-deleted";
+export const MOCK_APPLY_CHANGED_EVENT = "jobdri:mock-apply-changed";
const MOCK_APPLY_RESUME_STORAGE_KEY = "jobdri.mockApplyResumeRecords";
+const retryMockApplyRequests = new Map<
+ number,
+ Promise
+>();
+
+export function notifyMockApplyChanged() {
+ if (typeof window !== "undefined") {
+ window.dispatchEvent(new Event(MOCK_APPLY_CHANGED_EVENT));
+ }
+}
async function postMockApplyFromJobPosting(
path: string,
- jobPostingId: number,
+ payload: MockApplyFromJobPostingPayload,
fallbackMessage: string,
) {
const response = await fetch(`${API_BASE_URL}${path}`, {
@@ -69,24 +86,34 @@ async function postMockApplyFromJobPosting(
"Content-Type": "application/json",
...getAuthHeaders(),
},
- body: JSON.stringify({ jobPostingId }),
+ body: JSON.stringify(payload),
});
- return parseApiResponse(response, fallbackMessage);
+ const result = await parseApiResponse(
+ response,
+ fallbackMessage,
+ );
+
+ notifyMockApplyChanged();
+ return result;
}
-export function createMockApplyFromJobPosting(jobPostingId: number) {
+export function createMockApplyFromJobPosting(
+ payload: MockApplyFromJobPostingPayload,
+) {
return postMockApplyFromJobPosting(
"/api/mock-applies/mock/from-job-posting",
- jobPostingId,
+ payload,
"모의 서류 지원 생성에 실패했습니다.",
);
}
-export function createActualApplyFromJobPosting(jobPostingId: number) {
+export function createActualApplyFromJobPosting(
+ payload: MockApplyFromJobPostingPayload,
+) {
return postMockApplyFromJobPosting(
"/api/mock-applies/actual",
- jobPostingId,
+ payload,
"실제 공고 기반 서류 지원 생성에 실패했습니다.",
);
}
@@ -94,13 +121,15 @@ export function createActualApplyFromJobPosting(jobPostingId: number) {
export function createApplyFromJobPosting({
jobPostingId,
applyType,
+ sequence,
}: {
jobPostingId: number;
applyType: JobPostingApplyType;
+ sequence?: number;
}) {
return applyType === "MOCK"
- ? createMockApplyFromJobPosting(jobPostingId)
- : createActualApplyFromJobPosting(jobPostingId);
+ ? createMockApplyFromJobPosting({ jobPostingId, sequence })
+ : createActualApplyFromJobPosting({ jobPostingId, sequence });
}
export async function fetchMyMockApplies({
@@ -128,6 +157,50 @@ export async function fetchMyMockApplies({
};
}
+export async function deleteMockApply(mockApplyId: number) {
+ const response = await fetch(
+ `${API_BASE_URL}/api/mock-applies/${mockApplyId}`,
+ {
+ method: "DELETE",
+ headers: getAuthHeaders(),
+ },
+ );
+ const result = await parseApiResponseAllowNull(
+ response,
+ "모의 서류 지원 삭제에 실패했습니다.",
+ );
+
+ removeMockApplyResumeRecord(mockApplyId);
+
+ if (typeof window !== "undefined") {
+ window.dispatchEvent(
+ new CustomEvent(MOCK_APPLY_DELETED_EVENT, {
+ detail: mockApplyId,
+ }),
+ );
+ }
+ notifyMockApplyChanged();
+
+ return result;
+}
+
+export async function fetchMockApplyJobPosting(
+ mockApplyId: number,
+): Promise {
+ const response = await fetch(
+ `${API_BASE_URL}/api/mock-applies/${mockApplyId}/job-posting`,
+ {
+ headers: getAuthHeaders(),
+ cache: "no-store",
+ },
+ );
+
+ return parseApiResponse(
+ response,
+ "연결된 채용 공고를 불러오지 못했습니다.",
+ );
+}
+
export function saveSelectedApplyType(applyType: JobPostingApplyType) {
if (typeof window === "undefined") {
return;
@@ -219,6 +292,26 @@ export function saveMockApplyResumeRecord({
);
}
+export function removeMockApplyResumeRecord(mockApplyId: number) {
+ if (typeof window === "undefined") {
+ return;
+ }
+
+ const nextRecords = getMockApplyResumeRecords().filter(
+ (record) => record.mockApplyId !== mockApplyId,
+ );
+
+ if (nextRecords.length === 0) {
+ window.localStorage.removeItem(MOCK_APPLY_RESUME_STORAGE_KEY);
+ return;
+ }
+
+ window.localStorage.setItem(
+ MOCK_APPLY_RESUME_STORAGE_KEY,
+ JSON.stringify(nextRecords),
+ );
+}
+
export function updateMockApplyResumeStatus(
mockApplyId: number,
status: MockApplyProgressStatus,
@@ -242,19 +335,45 @@ export function updateMockApplyResumeStatus(
});
}
-export async function retryMockApply(
+export function retryMockApply(
mockApplyId: number,
): Promise {
- const response = await fetch(
+ const ongoingRequest = retryMockApplyRequests.get(mockApplyId);
+ if (ongoingRequest) {
+ return ongoingRequest;
+ }
+
+ const request = fetch(
`${API_BASE_URL}/api/mock-applies/${mockApplyId}/retry`,
{
method: "POST",
headers: getAuthHeaders(),
},
+ )
+ .then((response) =>
+ parseApiResponse(
+ response,
+ "재도전 모의 서류 지원 생성에 실패했습니다.",
+ ),
+ )
+ .then((result) => {
+ notifyMockApplyChanged();
+ return result;
+ });
+
+ retryMockApplyRequests.set(mockApplyId, request);
+ void request.then(
+ () => {
+ if (retryMockApplyRequests.get(mockApplyId) === request) {
+ retryMockApplyRequests.delete(mockApplyId);
+ }
+ },
+ () => {
+ if (retryMockApplyRequests.get(mockApplyId) === request) {
+ retryMockApplyRequests.delete(mockApplyId);
+ }
+ },
);
- return parseApiResponse(
- response,
- "재도전 모의 서류 지원 생성에 실패했습니다.",
- );
+ return request;
}
diff --git a/jobdri/src/lib/api/notification.ts b/jobdri/src/lib/api/notification.ts
index af12832..dcee63d 100644
--- a/jobdri/src/lib/api/notification.ts
+++ b/jobdri/src/lib/api/notification.ts
@@ -1,6 +1,20 @@
-import { fetchEventSource } from "@microsoft/fetch-event-source";
+import {
+ EventStreamContentType,
+ fetchEventSource,
+} from "@microsoft/fetch-event-source";
import { API_BASE_URL, getAuthHeaders } from "@/lib/auth";
+class NotificationStreamResponseError extends Error {
+ constructor(
+ message: string,
+ readonly status: number,
+ readonly contentType: string | null,
+ ) {
+ super(message);
+ this.name = "NotificationStreamResponseError";
+ }
+}
+
export interface ApiNotificationItem {
id: number;
type: string;
@@ -28,6 +42,7 @@ export interface LnbNotificationItem {
targetType?: string;
targetId?: string;
mockApplyId?: string;
+ taskId?: string;
apiType?: string;
readAt?: string;
}
@@ -69,51 +84,87 @@ export function subscribeToNotificationStream(
return () => ctrl.abort();
}
- const tokenOnly = headers.Authorization.replace("Bearer ", "");
+ fetchEventSource(`${API_BASE_URL}/api/notifications/stream`, {
+ method: "GET",
+ headers: {
+ ...headers,
+ accept: EventStreamContentType,
+ },
+ cache: "no-store",
+ signal: ctrl.signal,
+
+ async onopen(response) {
+ const contentType = response.headers.get("content-type");
+
+ if (response.ok && contentType?.startsWith(EventStreamContentType)) {
+ return;
+ }
+
+ const errorResponse = contentType?.includes("application/json")
+ ? ((await response
+ .clone()
+ .json()
+ .catch(() => null)) as {
+ message?: string;
+ error?: string;
+ } | null)
+ : null;
+
+ throw new NotificationStreamResponseError(
+ errorResponse?.message ||
+ errorResponse?.error ||
+ `알림 스트림 연결에 실패했습니다. (${response.status})`,
+ response.status,
+ contentType,
+ );
+ },
+
+ onmessage(event) {
+ if (!event.data || !event.data.trim().startsWith("{")) {
+ return;
+ }
- fetchEventSource(
- `${API_BASE_URL}/api/notifications/stream?accessToken=${tokenOnly}`,
- {
- method: "GET",
- headers: {
- ...headers,
- Accept: "text/event-stream",
- },
- signal: ctrl.signal,
+ try {
+ const parsedData = JSON.parse(event.data) as ApiNotificationItem;
- onmessage(event) {
- if (!event.data || !event.data.trim().startsWith("{")) {
+ if (!parsedData.title || parsedData.title.trim() === "") {
return;
}
- try {
- const parsedData = JSON.parse(event.data) as ApiNotificationItem;
-
- if (!parsedData.title || parsedData.title.trim() === "") {
- return;
- }
-
- onMessage(parsedData);
- } catch (e: unknown) {
- console.error("SSE 데이터 파싱 실패:", e);
- }
- },
+ onMessage(parsedData);
+ } catch (error: unknown) {
+ console.error("SSE 데이터 파싱 실패:", error);
+ }
+ },
- onerror(err: unknown) {
- if (err instanceof Error && err.name === "AbortError") {
- return;
- }
+ onerror(error: unknown) {
+ if (
+ (error instanceof Error && error.name === "AbortError") ||
+ error instanceof NotificationStreamResponseError
+ ) {
+ throw error;
+ }
- console.error("SSE 스트림 에러:", err);
- if (onError) onError(err);
- },
+ onError?.(error);
+ return 5_000;
},
- ).catch((err) => {
- if (err.name === "AbortError") {
- console.log("알림 스트림 연결이 정상적으로 해제되었습니다. (Abort)");
+ }).catch((error: unknown) => {
+ if (error instanceof Error && error.name === "AbortError") {
+ return;
+ }
+
+ if (error instanceof NotificationStreamResponseError) {
+ console.warn(
+ `실시간 알림 연결을 건너뜁니다: ${error.message}`,
+ {
+ status: error.status,
+ contentType: error.contentType,
+ },
+ );
return;
}
- console.error("SSE 연결 중 예외 발생:", err);
+
+ console.error("SSE 연결 중 예외 발생:", error);
});
return () => ctrl.abort();
diff --git a/jobdri/src/lib/api/questions.ts b/jobdri/src/lib/api/questions.ts
index 7bdc69e..03a261c 100644
--- a/jobdri/src/lib/api/questions.ts
+++ b/jobdri/src/lib/api/questions.ts
@@ -24,6 +24,27 @@ interface QuestionApiItem {
answer?: string;
}
+function mapQuestionItems(
+ questions: QuestionApiItem[] | undefined,
+): QuestionItem[] {
+ return (questions ?? []).map((question, index) => {
+ const questionId = question.questionId ?? question.id;
+
+ return {
+ id:
+ questionId !== undefined
+ ? String(questionId)
+ : `question-${index}`,
+ questionId,
+ question: question.content,
+ maxLength: question.charLimit,
+ selected: question.selected,
+ custom: question.custom,
+ answer: question.answer,
+ };
+ });
+}
+
interface SelectedQuestionsApiResponse {
mockApplyId: number;
status: string;
@@ -57,22 +78,13 @@ export async function fetchQuestions(
"문항 목록을 불러오지 못했습니다.",
);
- return (result ?? []).map(
- ({ id, content, charLimit, selected, questionId, custom }, index) => ({
- id: String(index),
- questionId: id ?? questionId,
- question: content,
- maxLength: charLimit,
- selected,
- custom,
- }),
- );
+ return mapQuestionItems(result);
}
export async function saveQuestions(
mockApplyId: number,
questions: QuestionItem[],
-): Promise {
+): Promise {
const response = await fetch(
`${API_BASE_URL}/api/mock-applies/${mockApplyId}/questions`,
{
@@ -88,7 +100,12 @@ export async function saveQuestions(
},
);
- await parseApiResponse(response, "문항 저장에 실패했습니다.");
+ const result = await parseApiResponse(
+ response,
+ "문항 저장에 실패했습니다.",
+ );
+
+ return mapQuestionItems(result?.questions);
}
export async function fetchSelectedQuestions(
@@ -106,26 +123,21 @@ export async function fetchSelectedQuestions(
"선택 문항 조회에 실패했습니다.",
);
- return (result?.questions ?? []).map(
- (
- { id, questionId, content, charLimit, selected, custom, answer },
- index,
- ) => ({
- id: String(index),
- questionId: id ?? questionId,
- question: content,
- maxLength: charLimit,
- selected,
- custom,
- answer,
- }),
- );
+ return mapQuestionItems(result?.questions);
}
export interface SaveApplyResult {
mockApplyId: number;
status: string;
sequence: number;
+ questions: QuestionItem[];
+}
+
+interface SaveApplyApiResult {
+ mockApplyId: number;
+ status: string;
+ sequence: number;
+ questions?: QuestionApiItem[];
}
export async function saveApply(
@@ -143,11 +155,17 @@ export async function saveApply(
},
);
- const result = await parseApiResponse(
+ const result = await parseApiResponse(
response,
"답변 제출에 실패했습니다.",
);
- return result!;
+
+ return {
+ mockApplyId: result!.mockApplyId,
+ status: result!.status,
+ sequence: result!.sequence,
+ questions: mapQuestionItems(result!.questions),
+ };
}
export async function createCustomQuestionCandidate(