diff --git a/src/entities/resume/model/resume.mutations.ts b/src/entities/resume/model/resume.mutations.ts index 1830241..c6c3632 100644 --- a/src/entities/resume/model/resume.mutations.ts +++ b/src/entities/resume/model/resume.mutations.ts @@ -43,8 +43,8 @@ export const useUpdateResume = (workspaceId: string, resumeId: string) => { const { updateResume } = await resumeAPI.updateResume({ workspaceId, resumeId, input }) return updateResume }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: resumeKeys.detail(workspaceId, resumeId) }) + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: resumeKeys.detail(workspaceId, resumeId) }) queryClient.invalidateQueries({ queryKey: resumeKeys.lists() }) queryClient.invalidateQueries({ queryKey: resumeKeys.counts(workspaceId) }) } diff --git a/src/features/resume_pdf_download/lib/generateResumePdf.tsx b/src/features/resume_pdf_download/lib/generateResumePdf.tsx index d33d04d..3043252 100644 --- a/src/features/resume_pdf_download/lib/generateResumePdf.tsx +++ b/src/features/resume_pdf_download/lib/generateResumePdf.tsx @@ -2,17 +2,23 @@ import { pdf } from '@react-pdf/renderer' import { ResumePdfDocument, type ResumePdfDocumentProps } from '../ui/ResumePdfDocument' import { registerPdfFonts } from './registerPdfFonts' -/** 다운로드 파일명. 이름이 있으면 `홍길동_이력서.pdf`, 없으면 `이력서.pdf`. OS에서 문제되는 문자만 제거한다. */ -const buildFileName = (name?: string | null): string => { - const safe = name?.trim().replace(/[\\/:*?"<>|]/g, '') - return safe ? `${safe}_이력서.pdf` : '이력서.pdf' +interface GenerateResumePdfParams extends ResumePdfDocumentProps { + companyName?: string | null + positionTitle?: string | null +} + +/** 다운로드 파일명 `{기업명}_{직무명}_이력서.pdf`. 비어 있는 부분은 건너뛰고, 둘 다 없으면 `이력서.pdf`. OS 금지 문자는 제거한다. */ +const buildFileName = (companyName?: string | null, positionTitle?: string | null): string => { + const sanitize = (value?: string | null) => value?.trim().replace(/[\\/:*?"<>|]/g, '') ?? '' + const parts = [sanitize(companyName), sanitize(positionTitle)].filter(Boolean) + return `${[...parts, '이력서'].join('_')}.pdf` } /** * 이력서 데이터를 PDF Blob으로 렌더한 뒤 브라우저 다운로드를 트리거한다. * `@react-pdf/renderer`는 브라우저 전용이라 이 모듈은 클라이언트에서 동적 import로만 불러야 한다. */ -export const generateResumePdf = async ({ basicInfo, sections }: ResumePdfDocumentProps): Promise => { +export const generateResumePdf = async ({ basicInfo, sections, companyName, positionTitle }: GenerateResumePdfParams): Promise => { registerPdfFonts() const blob = await pdf().toBlob() @@ -21,7 +27,7 @@ export const generateResumePdf = async ({ basicInfo, sections }: ResumePdfDocume try { const anchor = document.createElement('a') anchor.href = url - anchor.download = buildFileName(basicInfo?.name) + anchor.download = buildFileName(companyName, positionTitle) document.body.appendChild(anchor) anchor.click() anchor.remove() diff --git a/src/features/resume_pdf_download/ui/ResumeDownloadButton.tsx b/src/features/resume_pdf_download/ui/ResumeDownloadButton.tsx index 175a0fe..50f21d3 100644 --- a/src/features/resume_pdf_download/ui/ResumeDownloadButton.tsx +++ b/src/features/resume_pdf_download/ui/ResumeDownloadButton.tsx @@ -9,6 +9,9 @@ import type { ResumeSectionData } from '@entities/resume' interface ResumeDownloadButtonProps { basicInfo: ResumeBasicInfoFieldsFragment | null sections: ResumeSectionData[] + // 다운로드 파일명 `{기업명}_{직무명}_이력서.pdf`에 사용 + companyName?: string | null + positionTitle?: string | null } /** @@ -17,7 +20,7 @@ interface ResumeDownloadButtonProps { * `@react-pdf/renderer`는 브라우저 전용이자 번들이 무거워, 초기 로드에 포함하지 않고 * 클릭 시점에 생성 로직을 동적 import 한다(코드 스플리팅 + SSR 회피). */ -export const ResumeDownloadButton = ({ basicInfo, sections }: ResumeDownloadButtonProps) => { +export const ResumeDownloadButton = ({ basicInfo, sections, companyName, positionTitle }: ResumeDownloadButtonProps) => { const [isGenerating, setIsGenerating] = useState(false) const handleDownload = async () => { @@ -25,7 +28,8 @@ export const ResumeDownloadButton = ({ basicInfo, sections }: ResumeDownloadButt setIsGenerating(true) try { const { generateResumePdf } = await import('../lib/generateResumePdf') - await generateResumePdf({ basicInfo, sections }) + await generateResumePdf({ basicInfo, sections, companyName, positionTitle }) + toast.success('이력서 다운로드가 완료되었습니다.') } catch (error) { console.error('이력서 PDF 생성 실패', error) toast.error('PDF를 만드는 중 문제가 발생했어요. 잠시 후 다시 시도해 주세요.') diff --git a/src/features/resume_pdf_download/ui/ResumePdfDocument.tsx b/src/features/resume_pdf_download/ui/ResumePdfDocument.tsx index 220ef8b..83048a1 100644 --- a/src/features/resume_pdf_download/ui/ResumePdfDocument.tsx +++ b/src/features/resume_pdf_download/ui/ResumePdfDocument.tsx @@ -1,5 +1,5 @@ import { Document, Page, StyleSheet, Text, View } from '@react-pdf/renderer' -import { Fragment, type ReactNode } from 'react' +import { Children, Fragment, type ReactNode } from 'react' import type { ResumeBasicInfoFieldsFragment } from '@shared/lib/gql/graphql' import { formatDate, formatYYYYMM } from '@shared/lib' import { payloadsOf, visibleItems, type ResumeSectionData } from '@entities/resume' @@ -33,7 +33,9 @@ const styles = StyleSheet.create({ divider: { borderBottomWidth: 1, borderBottomColor: COLOR.divider, marginTop: 12, marginBottom: 24 }, sections: { flexDirection: 'column', gap: 44 }, - section: { flexDirection: 'row', gap: 48 }, + // 섹션 = [제목+첫 항목 줄] + [빈 열+나머지 항목 줄]을 세로로 쌓음. 세로 gap은 항목 간격(24)과 동일. + section: { flexDirection: 'column', gap: 24 }, + sectionLine: { flexDirection: 'row', gap: 48 }, // 제목(또는 빈 열) | 본문 sectionTitle: { width: 72, flexShrink: 0, fontSize: 13, color: COLOR.subtler }, sectionBody: { flex: 1, flexDirection: 'column', gap: 24 }, @@ -74,12 +76,25 @@ const Subtitle = ({ parts }: { parts: Array }) => { ) } -const SectionRow = ({ title, children }: { title: string; children: ReactNode }) => ( - - {title} - {children} - -) +const SectionRow = ({ title, children }: { title: string; children: ReactNode }) => { + const [first, ...rest] = Children.toArray(children) + return ( + // '제목 + 첫 항목'을 wrap={false}로 묶어, 페이지 경계에서 제목만 홀로 남는 분리를 막는다(묶음은 항목 하나 크기라 + // 항상 한 페이지에 들어감 → 겹침 없음). 나머지 항목은 자유롭게 나뉘어(빈 좌측 열로 정렬 유지) 큰 섹션도 이어진다. + + + {title} + {first} + + {rest.length > 0 && ( + + + {rest} + + )} + + ) +} /** * 제목 + 부제(+ 선택적 본문) 구조의 공통 아이템 블록. diff --git a/src/views/resume_detail/ui/ResumeDetailPage.tsx b/src/views/resume_detail/ui/ResumeDetailPage.tsx index ff04d8f..92fb70d 100644 --- a/src/views/resume_detail/ui/ResumeDetailPage.tsx +++ b/src/views/resume_detail/ui/ResumeDetailPage.tsx @@ -81,7 +81,7 @@ const ResumeToolbar = ({ resumeId, targetJd, basicInfo, sections }: ResumeToolba - + ) diff --git a/src/views/resume_edit/ui/form/FormInput.tsx b/src/views/resume_edit/ui/form/FormInput.tsx index 665b17b..a479350 100644 --- a/src/views/resume_edit/ui/form/FormInput.tsx +++ b/src/views/resume_edit/ui/form/FormInput.tsx @@ -22,7 +22,7 @@ export const FormInput = ({ name, ...props }: FormInputProps) => { } - render={({ field }) => } + render={({ field }) => } /> ) } diff --git a/src/views/resume_edit/ui/form/FormTextarea.tsx b/src/views/resume_edit/ui/form/FormTextarea.tsx index e3b6bc3..3d36cf3 100644 --- a/src/views/resume_edit/ui/form/FormTextarea.tsx +++ b/src/views/resume_edit/ui/form/FormTextarea.tsx @@ -34,6 +34,7 @@ export const FormTextarea = ({ name, sectionName, ...props }: FormTextareaProps) field.onChange(e) }} onBlur={field.onBlur} + maxLength={500} /> )} />