diff --git a/frontend/src/features/resume/api/resume.ts b/frontend/src/features/resume/api/resume.ts new file mode 100644 index 00000000..993438b4 --- /dev/null +++ b/frontend/src/features/resume/api/resume.ts @@ -0,0 +1,31 @@ +import { apiClient } from '@/shared/api' +import type { + PageResponse, + ResumeResponse, +} from '@/features/resume/model/types' + +export async function uploadResume(file: File): Promise { + const form = new FormData() + form.append('file', file) + const { data } = await apiClient.post('/api/resumes', form) + return data +} + +export async function listResumes( + page = 0, + size = 20, +): Promise> { + const { data } = await apiClient.get>( + '/api/resumes', { params: { page, size, sort: 'createdAt,desc' } }, + ) + return data +} + +export async function getResume(id: number): Promise { + const { data } = await apiClient.get(`/api/resumes/${id}`) + return data +} + +export async function deleteResume(id: number): Promise { + await apiClient.delete(`/api/resumes/${id}`) +} \ No newline at end of file diff --git a/frontend/src/features/resume/index.ts b/frontend/src/features/resume/index.ts index e69de29b..25e38e44 100644 --- a/frontend/src/features/resume/index.ts +++ b/frontend/src/features/resume/index.ts @@ -0,0 +1,7 @@ +export { ResumeList } from './ui/ResumeList' +export { UploadResumeButton } from './ui/UploadResumeButton' +export type { + PageResponse, + ResumeResponse, + ResumeStatus, +} from './model/types' diff --git a/frontend/src/features/resume/lib/file-validation.ts b/frontend/src/features/resume/lib/file-validation.ts new file mode 100644 index 00000000..39fdc3db --- /dev/null +++ b/frontend/src/features/resume/lib/file-validation.ts @@ -0,0 +1,42 @@ +export const RESUME_MAX_BYTES = 20 * 1024 * 1024 +export const RESUME_ACCEPT_MIME = 'application/pdf' +export const RESUME_ACCEPT_EXTENSION = '.pdf' + +export type ResumeFileErrorCode = + | 'RESUME_EMPTY_FILE' + | 'RESUME_INVALID_FILE_TYPE' + | 'RESUME_FILE_TOO_LARGE' + +export type ResumeFileError = { + code: ResumeFileErrorCode + message: string + maxBytes?: number +} + +export function validateResumeFile(file: File): ResumeFileError | null { + if (file.size === 0) { + return { code: 'RESUME_EMPTY_FILE', message: '빈 파일은 업로드할 수 없습니다.' } + } + const isPdfMime = file.type === RESUME_ACCEPT_MIME + const isPdfExt = file.name.toLowerCase().endsWith(RESUME_ACCEPT_EXTENSION) + if (!isPdfMime && !isPdfExt) { + return { + code: 'RESUME_INVALID_FILE_TYPE', + message: 'PDF 파일만 업로드할 수 있습니다.', + } + } + if (file.size > RESUME_MAX_BYTES) { + return { + code: 'RESUME_FILE_TOO_LARGE', + message: `파일 크기가 ${Math.floor(RESUME_MAX_BYTES / 1024 / 1024)}MB를 초과합니다.`, + maxBytes: RESUME_MAX_BYTES, + } + } + return null +} + +export function formatFileSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B` + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB` + return `${(bytes / 1024 / 1024).toFixed(1)} MB` +} diff --git a/frontend/src/features/resume/model/types.ts b/frontend/src/features/resume/model/types.ts new file mode 100644 index 00000000..ce1551ba --- /dev/null +++ b/frontend/src/features/resume/model/types.ts @@ -0,0 +1,20 @@ +export type ResumeStatus = 'PENDING' | 'ANALYZING' | 'ANALYZED' | 'FAILED' + +export type ResumeResponse = { + id: number + originalFilename: string + fileSize: number + status: ResumeStatus + createdAt: string + updatedAt: string +} + +export type PageResponse = { + content: T[] + page: number + size: number + totalElements: number + totalPages: number + first: boolean + last: boolean +} diff --git a/frontend/src/features/resume/model/useResumeUpload.ts b/frontend/src/features/resume/model/useResumeUpload.ts new file mode 100644 index 00000000..23860ed2 --- /dev/null +++ b/frontend/src/features/resume/model/useResumeUpload.ts @@ -0,0 +1,59 @@ +import { useRef, useState } from 'react' +import { isApiError } from '@/shared/api' +import { useUploadResumeMutation } from '@/features/resume/model/useResumes' +import { + RESUME_ACCEPT_EXTENSION, + RESUME_ACCEPT_MIME, + validateResumeFile, +} from '@/features/resume/lib/file-validation' + +const RESUME_ACCEPT = `${RESUME_ACCEPT_MIME},${RESUME_ACCEPT_EXTENSION}` + +type Options = { + onError?: (message: string) => void +} + +export function useResumeUpload({ onError }: Options = {}) { + const inputRef = useRef(null) + const upload = useUploadResumeMutation() + const [error, setError] = useState(null) + + const fail = (message: string) => { + setError(message) + onError?.(message) + } + + const handleFile = (file: File) => { + setError(null) + const invalid = validateResumeFile(file) + if (invalid) { + fail(invalid.message) + return + } + upload.mutate(file, { + onError: (err) => { + fail( + isApiError(err) ? err.message : '업로드 중 오류가 발생했습니다.', + ) + }, + }) + } + + const openPicker = () => inputRef.current?.click() + + const onInputChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + if (file) handleFile(file) + e.target.value = '' + } + + return { + inputRef, + accept: RESUME_ACCEPT, + error, + isPending: upload.isPending, + openPicker, + handleFile, + onInputChange, + } +} diff --git a/frontend/src/features/resume/model/useResumes.ts b/frontend/src/features/resume/model/useResumes.ts new file mode 100644 index 00000000..37bde3e7 --- /dev/null +++ b/frontend/src/features/resume/model/useResumes.ts @@ -0,0 +1,64 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { + deleteResume, + getResume, + listResumes, + uploadResume, +} from '@/features/resume/api/resume' +import type { + PageResponse, + ResumeResponse, +} from '@/features/resume/model/types' + +const RESUMES_KEY = 'resumes' as const +const POLL_INTERVAL_MS = 2_000 + +function hasInProgress(page: PageResponse | undefined): boolean { + if (!page) return false + return page.content.some( + (r) => r.status === 'PENDING' || r.status === 'ANALYZING', + ) +} + +export function useResumesQuery(page = 0, size = 20) { + return useQuery({ + queryKey: [RESUMES_KEY, page, size], + queryFn: () => listResumes(page, size), + refetchInterval: (query) => + hasInProgress(query.state.data) ? POLL_INTERVAL_MS : false, + }) +} + +export function useResumeQuery(id: number | null) { + return useQuery({ + queryKey: [RESUMES_KEY, 'detail', id], + queryFn: () => getResume(id as number), + enabled: id !== null, + refetchInterval: (query) => { + const status = query.state.data?.status + return status === 'PENDING' || status === 'ANALYZING' + ? POLL_INTERVAL_MS + : false + }, + }) +} + +export function useUploadResumeMutation() { + const qc = useQueryClient() + return useMutation({ + mutationFn: (file: File) => uploadResume(file), + onSuccess: () => { + qc.invalidateQueries({ queryKey: [RESUMES_KEY] }) + }, + }) +} + +export function useDeleteResumeMutation() { + const qc = useQueryClient() + return useMutation({ + mutationFn: (id: number) => deleteResume(id), + onSuccess: () => { + qc.invalidateQueries({ queryKey: [RESUMES_KEY] }) + }, + }) +} diff --git a/frontend/src/features/resume/ui/ResumeDropzone.tsx b/frontend/src/features/resume/ui/ResumeDropzone.tsx new file mode 100644 index 00000000..06598df3 --- /dev/null +++ b/frontend/src/features/resume/ui/ResumeDropzone.tsx @@ -0,0 +1,108 @@ +import { useEffect, useState } from 'react' +import { useResumeUpload } from '@/features/resume/model/useResumeUpload' + +const FAKE_PROGRESS_DURATION_MS = 1_500 + +export function ResumeDropzone() { + const { + inputRef, + accept, + error, + isPending, + openPicker, + onInputChange, + handleFile, + } = useResumeUpload() + const [dragOver, setDragOver] = useState(false) + const progress = useFakeProgress(isPending) + + const onDrop = (e: React.DragEvent) => { + e.preventDefault() + setDragOver(false) + const file = e.dataTransfer.files?.[0] + if (file) handleFile(file) + } + + return ( +
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + openPicker() + } + }} + onDragOver={(e) => { + e.preventDefault() + setDragOver(true) + }} + onDragLeave={() => setDragOver(false)} + onDrop={onDrop} + className={[ + 'relative rounded-xl border border-dashed p-10 text-center cursor-pointer transition-colors duration-fast', + dragOver + ? 'border-primary bg-surface' + : 'border-border-strong bg-surface hover:border-primary/60', + isPending ? 'pointer-events-none' : '', + ].join(' ')} + > + +

+ PDF 이력서를 드래그하거나 클릭해서 업로드 +

+

+ 최대 20MB · PDF 형식만 지원 +

+ {error ? ( +

+ {error} +

+ ) : null} + {isPending ? ( +
+
+
+
+

+ 업로드 중… {Math.floor(progress)}% +

+
+ ) : null} +
+ ) +} + +function useFakeProgress(active: boolean): number { + const [progress, setProgress] = useState(0) + useEffect(() => { + if (!active) { + setProgress(0) + return + } + const start = Date.now() + const id = window.setInterval(() => { + const elapsed = Date.now() - start + const p = Math.min(100, (elapsed / FAKE_PROGRESS_DURATION_MS) * 100) + setProgress(p) + if (p >= 100) window.clearInterval(id) + }, 60) + return () => window.clearInterval(id) + }, [active]) + return progress +} diff --git a/frontend/src/features/resume/ui/ResumeList.tsx b/frontend/src/features/resume/ui/ResumeList.tsx new file mode 100644 index 00000000..133dd07c --- /dev/null +++ b/frontend/src/features/resume/ui/ResumeList.tsx @@ -0,0 +1,174 @@ +import { isApiError } from '@/shared/api' +import { + useDeleteResumeMutation, + useResumesQuery, +} from '@/features/resume/model/useResumes' +import { formatFileSize } from '@/features/resume/lib/file-validation' +import type { + ResumeResponse, + ResumeStatus, +} from '@/features/resume/model/types' +import { ResumeDropzone } from './ResumeDropzone' + +export function ResumeList() { + const { data, isLoading, isError, error, refetch } = useResumesQuery() + + if (isLoading) return + if (isError) { + const message = isApiError(error) + ? error.message + : '이력서 목록을 불러오지 못했습니다.' + return ( +
+

+ {message} +

+ +
+ ) + } + + const resumes = data?.content ?? [] + if (resumes.length === 0) return + + return ( +
    + {resumes.map((resume) => ( +
  • + +
  • + ))} +
+ ) +} + +function ResumeCard({ resume }: { resume: ResumeResponse }) { + const remove = useDeleteResumeMutation() + const handleDelete = () => { + // TODO: shared/ui/ConfirmDialog 도입 시 교체 (docs/ui-patterns.md §4) + const ok = window.confirm( + `'${resume.originalFilename}' 이력서를 삭제하시겠습니까?`, + ) + if (!ok) return + remove.mutate(resume.id) + } + return ( +
+ +
+

+ {resume.originalFilename} +

+

+ {formatFileSize(resume.fileSize)} · {relativeTime(resume.createdAt)} +

+
+ + +
+ ) +} + +function ResumeListSkeleton() { + return ( +
    + {[0, 1, 2].map((i) => ( +
  • +
    +
    +
    +
    +
    +
    +
  • + ))} +
+ ) +} + +const STATUS_LABEL: Record = { + PENDING: '분석 대기', + ANALYZING: '분석 중', + ANALYZED: '분석 완료', + FAILED: '분석 실패', +} + +const STATUS_CLASS: Record = { + PENDING: 'bg-info-50 text-info-700', + ANALYZING: 'bg-info-50 text-info-700', + ANALYZED: 'bg-success-50 text-success-700', + FAILED: 'bg-danger-50 text-danger-700', +} + +function StatusBadge({ status }: { status: ResumeStatus }) { + const inProgress = status === 'PENDING' || status === 'ANALYZING' + return ( + + {inProgress ? : null} + {STATUS_LABEL[status]} + + ) +} + +function Spinner() { + return ( + + + + + ) +} + +function FileIcon() { + return ( +
+ PDF +
+ ) +} + +function relativeTime(iso: string): string { + const diffMs = Date.now() - new Date(iso).getTime() + if (diffMs < 0) return '방금 전' + const sec = diffMs / 1000 + if (sec < 60) return '방금 전' + const min = sec / 60 + if (min < 60) return `${Math.floor(min)}분 전` + const hr = min / 60 + if (hr < 24) return `${Math.floor(hr)}시간 전` + const day = hr / 24 + if (day < 7) return `${Math.floor(day)}일 전` + return new Date(iso).toLocaleDateString('ko-KR') +} diff --git a/frontend/src/features/resume/ui/UploadResumeButton.tsx b/frontend/src/features/resume/ui/UploadResumeButton.tsx new file mode 100644 index 00000000..881d6116 --- /dev/null +++ b/frontend/src/features/resume/ui/UploadResumeButton.tsx @@ -0,0 +1,36 @@ +import { useResumeUpload } from '@/features/resume/model/useResumeUpload' + +type Props = { + onError?: (message: string) => void +} + +export function UploadResumeButton({ onError }: Props) { + const { inputRef, accept, error, isPending, openPicker, onInputChange } = + useResumeUpload({ onError }) + + return ( +
+ + + {error ? ( +

+ {error} +

+ ) : null} +
+ ) +} diff --git a/frontend/src/pages/Workspace/ui/WorkspacePage.tsx b/frontend/src/pages/Workspace/ui/WorkspacePage.tsx index 3b964b3b..3bbc74e8 100644 --- a/frontend/src/pages/Workspace/ui/WorkspacePage.tsx +++ b/frontend/src/pages/Workspace/ui/WorkspacePage.tsx @@ -1,3 +1,4 @@ +import { ResumeList, UploadResumeButton } from '@/features/resume' import { SiteNav } from '@/widgets/site-nav' import { SiteFooter } from '@/widgets/site-footer' import { WorkspaceProfileCard } from '@/widgets/workspace-profile-card' @@ -14,8 +15,9 @@ export default function WorkspacePage() { } > - + -

아직 등록된 이력서가 없습니다.

-

- 다음 단계에서 PDF 업로드가 가능해집니다. -

-
- ) -} - function EmptyRepoSlot() { return (