feat(resume): admin bulk resume import
Adds the dashboard surface for the backend's /api/admin/v1/resume/import-batches endpoints, and the multipart support it needed. Client: - rawRequest passes a FormData body through untouched and skips Content-Type, so the browser sets multipart/form-data *with the boundary*. Setting it by hand stripped the boundary and the server saw an empty upload — verified against the backend: 202 with the fix, 422 VALIDATION_ERROR without it. This was the first admin endpoint in the codebase to accept a file. - New api.upload(). fetch reports no upload progress, so upload UI stays indeterminate rather than faking a percentage. UI (/resume/import-batches, gated on admin:resume:read; writes on :manage): - Upload dialog with countryCode and dry-run checked by default — seeing the collision report before creating accounts is almost always what you want. - Detail sheet polls every 2s and stops once the batch is terminal. Clickable count tiles filter the per-resume report; step statuses surface `命中缓存` / `复用结果` so the dedup is visible. - Confirm a dry run, retry failed rows, and resolve a missing contact. IMPORT_IDENTITY_TAKEN is shown inline — a collision is expected input, not an error toast. - progress.total is 0 until unpacking finishes, so both views render 准备中… rather than "0 / 0", which would read as an empty batch. types.gen.ts regenerated from the backend's admin spec. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
import { api } from '@/api/client'
|
||||
import type {
|
||||
ApiResponse,
|
||||
ListParams,
|
||||
PaginatedResponse,
|
||||
ResumeImportBatch,
|
||||
ResumeImportItem,
|
||||
} from '@/api/types'
|
||||
|
||||
const BASE = '/api/admin/v1/resume/import-batches'
|
||||
|
||||
export interface CreateBatchOptions {
|
||||
/** ISO 3166-1 alpha-3. Supplies the region for phone numbers that omit a country code. */
|
||||
countryCode?: string
|
||||
languageHint?: string
|
||||
/** Parse and report, but create no users until `confirmBatch`. */
|
||||
dryRun?: boolean
|
||||
}
|
||||
|
||||
export function fetchImportBatches(params: ListParams) {
|
||||
return api.get<PaginatedResponse<ResumeImportBatch>>(BASE, params)
|
||||
}
|
||||
|
||||
export function fetchImportBatch(id: string, signal?: AbortSignal) {
|
||||
return api.get<ApiResponse<ResumeImportBatch>>(`${BASE}/${id}`, undefined, signal)
|
||||
}
|
||||
|
||||
export function fetchImportBatchItems(id: string, params: ListParams) {
|
||||
return api.get<PaginatedResponse<ResumeImportItem>>(`${BASE}/${id}/items`, params)
|
||||
}
|
||||
|
||||
export function fetchImportBatchItem(batchId: string, itemId: string) {
|
||||
return api.get<ApiResponse<ResumeImportItem>>(`${BASE}/${batchId}/items/${itemId}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a ZIP of resumes. Returns as soon as the server accepts it (202) —
|
||||
* unpacking and parsing happen on a queue, so poll `fetchImportBatch`.
|
||||
*/
|
||||
export function createImportBatch(archive: File, options: CreateBatchOptions = {}) {
|
||||
const form = new FormData()
|
||||
form.append('archive', archive)
|
||||
if (options.countryCode) form.append('countryCode', options.countryCode)
|
||||
if (options.languageHint) form.append('languageHint', options.languageHint)
|
||||
if (options.dryRun) form.append('dryRun', '1')
|
||||
|
||||
return api.upload<ApiResponse<ResumeImportBatch>>(BASE, form)
|
||||
}
|
||||
|
||||
/** Turn a dry run into real accounts. Reuses the stored results — nothing re-parses. */
|
||||
export function confirmImportBatch(id: string) {
|
||||
return api.post<ApiResponse<ResumeImportBatch>>(`${BASE}/${id}/confirm`)
|
||||
}
|
||||
|
||||
export function retryImportBatch(id: string) {
|
||||
return api.post<ApiResponse<{ requeued: number }>>(`${BASE}/${id}/retry`)
|
||||
}
|
||||
|
||||
/** Resumes from whichever step failed; already-extracted text is reused. */
|
||||
export function retryImportItem(batchId: string, itemId: string) {
|
||||
return api.post<ApiResponse<ResumeImportItem>>(`${BASE}/${batchId}/items/${itemId}/retry`)
|
||||
}
|
||||
|
||||
/** Resolve a `needs_contact` row. The phone is normalized to E.164 server-side. */
|
||||
export function updateImportItemContact(
|
||||
batchId: string,
|
||||
itemId: string,
|
||||
contact: { phone?: string; email?: string },
|
||||
) {
|
||||
return api.patch<ApiResponse<ResumeImportItem>>(`${BASE}/${batchId}/items/${itemId}`, contact)
|
||||
}
|
||||
Reference in New Issue
Block a user