Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
8 changes: 8 additions & 0 deletions src/api/paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,14 @@ export const paths = {
`${API_PREFIX}/recruitments/${recruitmentId}/applications`,
application: (recruitmentId: number | string, applicationId: number | string) =>
`${API_PREFIX}/recruitments/${recruitmentId}/applications/${applicationId}`,
applicationFiles: (recruitmentId: number | string) =>
`/api/v1/recruitments/${recruitmentId}/application-files`,
applicationFileDownload: (
recruitmentId: number | string,
applicationId: number | string,
fileId: number | string,
) =>
`/api/v1/recruitments/${recruitmentId}/applications/${applicationId}/files/${fileId}/download`,
},
schedules: {
root: `${API_PREFIX}/schedules`,
Expand Down
31 changes: 30 additions & 1 deletion src/api/recruitments.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { request } from './client'
import { request, requestBlob } from './client'
import { paths } from './paths'
import type {
AppliedRecruitment,
Expand All @@ -14,6 +14,7 @@ import type {
UpdateRecruitmentRequest,
RecruitmentApplication,
RecruitmentApplicationDetail,
ApplicationFile,
} from '../types/recruitment'
import type { CursorPage } from '../types/project'

Expand Down Expand Up @@ -166,3 +167,31 @@ export async function getApplication(
url: paths.recruitments.application(recruitmentId, applicationId),
})
}

/** 파일 하나씩 업로드하고 받은 id를 지원 API의 fileIds로 넘긴다 */
export async function uploadApplicationFile(
recruitmentId: number,
file: File,
): Promise<ApplicationFile> {
const formData = new FormData()
formData.append('file', file)

return request({
method: 'POST',
url: paths.recruitments.applicationFiles(recruitmentId),
data: formData,
// 기본 헤더가 application/json이라 지우지 않으면 multipart boundary가 빠진다
headers: { 'Content-Type': undefined },
})
}

export async function downloadApplicationFile(
recruitmentId: number,
applicationId: number,
fileId: number,
): Promise<Blob> {
return requestBlob({
method: 'GET',
url: paths.recruitments.applicationFileDownload(recruitmentId, applicationId, fileId),
})
}
13 changes: 13 additions & 0 deletions src/constants/applicationFile.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
export const MAX_APPLICATION_FILES = 10
export const MAX_APPLICATION_FILE_SIZE = 100 * 1024 * 1024

/** 확장자 → 허용 MIME. BE가 둘 다 검사하므로 프론트도 같은 기준으로 거른다 */
export const ALLOWED_APPLICATION_FILE_TYPES: Record<string, string[]> = {
pdf: ['application/pdf'],
jpg: ['image/jpeg'],
jpeg: ['image/jpeg'],
png: ['image/png'],
webp: ['image/webp'],
zip: ['application/zip', 'application/x-zip-compressed'],
mp4: ['video/mp4'],
}
35 changes: 22 additions & 13 deletions src/domains/recruit/ApplicationInfoCard.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import type { ApplicationInfo } from '../../types/Recruit.types'
import type { ApplicationFile } from '../../types/recruitment'
import { formatFileSize } from '../../utils/applicationFile'

interface ApplicationInfoCardProps {
application: ApplicationInfo
onDownload?: (file: ApplicationFile) => void
}

function ApplicationInfoCard({ application }: ApplicationInfoCardProps) {
const { comment, referenceLink, fileName, fileUrl } = application
function ApplicationInfoCard({ application, onDownload }: ApplicationInfoCardProps) {
const { comment, referenceLink, files } = application

return (
<section className="flex flex-col gap-4 rounded-xl bg-white p-6 shadow-xs">
Expand Down Expand Up @@ -36,17 +39,23 @@ function ApplicationInfoCard({ application }: ApplicationInfoCardProps) {

<div className="grid grid-cols-[100px_1fr] items-start gap-4">
<span className="text-caption-lg text-neutral-11 font-semibold">첨부 파일</span>
{fileName && fileUrl ? (
<a
href={fileUrl}
target="_blank"
rel="noopener noreferrer"
className="text-caption-lg text-primary break-all underline"
>
{fileName}
</a>
) : fileName ? (
<span className="text-caption-lg text-neutral-6 break-all">{fileName}</span>
{files && files.length > 0 ? (
<ul className="flex flex-col gap-1.5">
{files.map((file) => (
<li key={file.id} className="flex items-center gap-2">
<button
type="button"
onClick={() => onDownload?.(file)}
className="text-caption-lg text-primary break-all underline"
>
{file.fileName}
</button>
<span className="text-caption-sm text-neutral-5 shrink-0">
{formatFileSize(file.fileSize)}
</span>
</li>
))}
</ul>
) : (
<span className="text-caption-lg text-neutral-6">-</span>
)}
Expand Down
81 changes: 70 additions & 11 deletions src/domains/recruit/ApplyModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,21 +6,31 @@ import Input from '../../components/Input'
import { Button } from '../../components/Button'
import { validateField } from '../../utils/validateField'
import { applicationSchema, type ApplicationValues } from '../../schemas/jobApplication'
import { uploadApplicationFile } from '../../api/recruitments'
import { validateApplicationFile, formatFileSize } from '../../utils/applicationFile'

interface ApplyModalProps {
isOpen: boolean
recruitmentId: number
onClose: () => void
onSubmit: (values: ApplicationValues) => Promise<void>
}

/** 업로드는 제출 시점에 하므로 선택한 파일 자체를 들고 있는다 */
type AttachedFile = {
file: File
error?: string
}

const INITIAL_VALUES: ApplicationValues = {
comment: '',
referenceLink: '',
file: null,
fileIds: [],
}

function ApplyModal({ isOpen, onClose, onSubmit }: ApplyModalProps) {
function ApplyModal({ isOpen, recruitmentId, onClose, onSubmit }: ApplyModalProps) {
const [values, setValues] = useState<ApplicationValues>(INITIAL_VALUES)
const [attached, setAttached] = useState<AttachedFile | null>(null)
const [errors, setErrors] = useState<Record<'comment' | 'referenceLink', string>>({
comment: '',
referenceLink: '',
Expand All @@ -31,6 +41,7 @@ function ApplyModal({ isOpen, onClose, onSubmit }: ApplyModalProps) {

const handleClose = () => {
setValues(INITIAL_VALUES)
setAttached(null)
setErrors({ comment: '', referenceLink: '' })
setIsSubmitted(false)
setSubmitError(null)
Expand All @@ -42,6 +53,16 @@ function ApplyModal({ isOpen, onClose, onSubmit }: ApplyModalProps) {
setErrors((prev) => ({ ...prev, [field]: message }))
}

// 선택 시에는 검증만 한다 — 취소·교체 시 서버에 고아 파일이 남지 않도록 업로드는 제출 시점에
const handleFileSelect = (file: File) => {
const invalidMessage = validateApplicationFile(file)
setAttached({ file, error: invalidMessage ?? undefined })
}

const handleFileRemove = () => {
setAttached(null)
}

const handleSubmit = async () => {
const commentError = validateField(applicationSchema.shape.comment, values.comment)
const linkError = validateField(applicationSchema.shape.referenceLink, values.referenceLink)
Expand All @@ -54,7 +75,13 @@ function ApplyModal({ isOpen, onClose, onSubmit }: ApplyModalProps) {
setSubmitting(true)
setSubmitError(null)
try {
await onSubmit(values)
// 업로드 후 반환된 id를 지원 요청의 fileIds로 넘긴다
let fileIds: number[] = []
if (attached && !attached.error) {
const uploaded = await uploadApplicationFile(recruitmentId, attached.file)
fileIds = [uploaded.id]
}
await onSubmit({ ...values, fileIds })
setIsSubmitted(true)
} catch {
setSubmitError('지원에 실패했습니다. 잠시 후 다시 시도해주세요.')
Expand All @@ -63,6 +90,9 @@ function ApplyModal({ isOpen, onClose, onSubmit }: ApplyModalProps) {
}
}

// 형식·용량이 맞지 않는 파일이 남아 있으면 제출을 막는다
const isFileBlocking = attached?.error != null

if (isSubmitted) {
return (
<Modal isOpen={isOpen} onClose={handleClose}>
Expand Down Expand Up @@ -100,18 +130,47 @@ function ApplyModal({ isOpen, onClose, onSubmit }: ApplyModalProps) {
error={errors.referenceLink}
/>

<FileInput
label="파일 첨부 (선택)"
value={values.file ? [values.file] : []}
onChange={(files) => setValues((prev) => ({ ...prev, file: files[0] ?? null }))}
accept=".png,.pdf,.doc,.docx,.jpg,.jpeg"
hint="첨부가능 파일 형식 (Png, Pdf, Word, Jpg) 최대 5GB"
/>
<div className="flex flex-col gap-2">
<FileInput
label="파일 첨부 (선택)"
value={[]}
onChange={(files) => {
if (files[0]) handleFileSelect(files[0])
}}
accept=".pdf,.jpg,.jpeg,.png,.webp,.zip,.mp4"
hint="pdf, jpg, png, webp, zip, mp4 · 최대 100MB"
disabled={submitting}
/>

{attached && (
<div className="bg-neutral-2 flex items-center gap-2 rounded-lg px-3 py-2">
<span className="text-caption-lg text-neutral-9 flex-1 truncate">
{attached.file.name}
<span className="text-neutral-5"> · {formatFileSize(attached.file.size)}</span>
</span>
{attached.error && (
<span className="text-caption-sm text-warning shrink-0">{attached.error}</span>
)}
<button
type="button"
onClick={handleFileRemove}
aria-label="첨부 파일 삭제"
className="text-neutral-5 hover:text-neutral-9 shrink-0 px-1"
>
</button>
</div>
)}
</div>

{submitError && <p className="text-caption-sm text-warning">{submitError}</p>}

<div className="flex justify-center gap-3">
<Button onClick={() => void handleSubmit()} disabled={submitting} className="w-52">
<Button
onClick={() => void handleSubmit()}
disabled={submitting || isFileBlocking}
className="w-52"
>
{submitting ? '지원 중…' : '지원하기'}
</Button>
<Button variant="secondary" onClick={handleClose} className="w-52">
Expand Down
35 changes: 35 additions & 0 deletions src/mocks/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,18 @@ export type MockProjectFileRecord = Omit<ProjectFile, 'uploader'> & {
uploaderId: number
}

export type MockApplicationFile = {
id: number
recruitmentId: number
userId: number
/** 지원에 연결되기 전에는 null */
applicationId: number | null
fileName: string
contentType: string
fileSize: number
createdAt: string
}

/* 모의 데이터베이스 타입 정의 */
export type MockDb = {
currentUserId: number | null
Expand All @@ -153,6 +165,7 @@ export type MockDb = {
shareLinks: ShareLink[]
recruitments: MockRecruitmentRecord[]
applications: Application[]
applicationFiles: MockApplicationFile[]
recruitmentBookmarks: Array<{ userId: number; recruitmentId: number }>
schedules: Schedule[]
notifications: AppNotification[]
Expand Down Expand Up @@ -579,6 +592,28 @@ export const db: MockDb = {
createdAt: '2026-06-16T00:00:00Z',
},
],
applicationFiles: [
{
id: 9001,
recruitmentId: 1,
userId: 2,
applicationId: 1,
fileName: '포트폴리오.pdf',
contentType: 'application/pdf',
fileSize: 2048576,
createdAt: '2026-08-12T00:00:00.000Z',
},
{
id: 9002,
recruitmentId: 1,
userId: 2,
applicationId: 1,
fileName: '편집_샘플.mp4',
contentType: 'video/mp4',
fileSize: 52428800,
createdAt: '2026-08-12T00:00:00.000Z',
},
],
recruitmentBookmarks: [{ userId: completeUser.id, recruitmentId: 2 }],
schedules: [
{
Expand Down
Loading
Loading