export type OrgStatus = 'INFO_PENDING' | 'READY' | 'PARTIAL' | 'ACTIVE' export type ContactCategory = 'APPLICANT' | 'MJ' | 'LAWYER' | 'OPERATOR' | 'ITN' /** * 공통코드 한 건. 화면 선택지는 소스가 아니라 DB에서 온다. * * code는 실제로 저장되는 값이고 label은 화면 표시명이다. 둘이 다를 수 있다 - * 권리확인 세부항목은 저장값이 길고 화면에는 짧은 형태로 나온다. * attrs에는 코드별 부가 속성이 들어간다(색 토큰, 쓰이는 자리, 추천값 등). */ export interface Code { code: string label: string sortOrder: number attrs: Record } /** 그룹ID -> 코드 목록(정렬순, 사용 중인 것만). */ export type CodeMap = Record /** 담당자관리에 등록된 담당자 1명. 신청기관 담당자는 affiliation에 소속 기관명이 들어간다. */ export interface Contact { id: number category: ContactCategory name: string affiliation: string | null deptName: string | null title: string | null phone: string | null email: string | null } /** 담당자 등록/수정 폼 입력. 폼 상태는 문자열로만 다루므로 빈 문자열이 "값 없음"을 뜻한다. */ export interface ContactInput { category: ContactCategory name: string affiliation: string deptName: string title: string phone: string email: string } export interface Org { id: number orgNo: string orgName: string status: OrgStatus applicant: Contact | null mj: Contact | null itn: Contact | null lawyer: Contact | null lawyerAssignedDate: string | null channelIdMj: string | null channelIdLaw: string | null /** null이면 아직 어떤 진행단계도 시작되지 않은 것이다(채널 생성 전). 1..12. */ stage: number | null } /** 채널관리 화면이 신청기관/문정원/아이티앤/변호사 배정을 한 번에 바꿀 때 보내는 요청. null은 해제다. */ export interface AssignmentInput { applicantContactId: number | null mjContactId: number | null itnContactId: number | null lawyerContactId: number | null lawyerAssignedDate: string | null } export type ProvisionOutcome = 'CACHED' | 'RECOVERED' | 'CREATED' | 'FAILED' export interface ProvisionResult { mj: ProvisionOutcome law: ProvisionOutcome message: string } export interface SeedReport { created: number updated: number total: number } export interface FileRef { id: string name: string size: number } export interface PostView { id: string user: string message: string createAt: number system: boolean files: FileRef[] } export interface FileView { id: string name: string size: number mimeType: string createAt: number uploader: string } export type ChannelKindKey = 'mj' | 'law' export interface WorkMemo { id: number orgId: number contactName: string body: string createdAt: number } /** 타임라인 탭 한 줄. detail은 STAGE 이벤트에서는 항상 null이다. */ export interface TimelineEvent { type: 'STAGE' | 'MEMO' | 'FILE' at: number title: string detail: string | null } /** * 서버가 4xx/5xx로 응답했을 때 던지는 예외. status를 함께 들고 있어야 호출자가 * "인증이 끊긴 것(401)"과 "그 밖의 모든 오류"를 구분해서 다르게 처리할 수 있다 - * 그렇지 않으면(예: 평범한 Error) 서버 오류나 네트워크 문제까지 전부 로그아웃으로 * 오인해 사용자를 로그인 화면으로 쫓아내게 된다. */ export class ApiError extends Error { constructor( public status: number, message: string, ) { super(message) this.name = 'ApiError' } } /** Spring Security가 내려주는 CSRF 쿠키를 읽어 헤더로 되돌려 보낸다. */ function csrfToken(): string { const match = document.cookie.match(/(?:^|;\s*)XSRF-TOKEN=([^;]*)/) return match ? decodeURIComponent(match[1]) : '' } async function request(url: string, init: RequestInit = {}): Promise { const method = init.method ?? 'GET' const headers: Record = { ...(init.headers as Record) } if (method !== 'GET') { headers['X-XSRF-TOKEN'] = csrfToken() } const response = await fetch(url, { ...init, method, headers, credentials: 'same-origin' }) if (!response.ok) { const body = await response.text() throw new ApiError(response.status, `요청 실패 (${response.status}): ${body}`) } return response.json() as Promise } export function getOrgs(): Promise { return request('/api/orgs') } /** 모든 화면 선택지를 한 번에 받는다. 로그인 직후 1회만 부른다. */ export function getCodes(): Promise { return request('/api/meta/codes') } /** 자료실에 올려둔 공용 서식(요구사항 [26]). 기관에 딸리지 않는다. */ export interface SharedFile { id: number fileName: string description: string | null byteSize: number uploadedBy: string uploadedAt: number } export function getSharedFiles(): Promise { return request('/api/shared-files') } export async function addSharedFile(file: File, description: string): Promise { const form = new FormData() form.append('file', file) if (description) { form.append('description', new Blob([description], { type: 'text/plain' })) } const res = await fetch('/api/shared-files', { method: 'POST', headers: { 'X-XSRF-TOKEN': csrfToken() }, credentials: 'same-origin', body: form, }) if (!res.ok) { throw new ApiError(res.status, `자료 업로드 실패 (${res.status}): ${await res.text()}`) } return res.json() as Promise } export function updateSharedFileDescription(id: number, description: string): Promise { return request(`/api/shared-files/${id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ description }), }) } export async function deleteSharedFile(id: number): Promise { await fetch(`/api/shared-files/${id}`, { method: 'DELETE', headers: { 'X-XSRF-TOKEN': csrfToken() }, credentials: 'same-origin', }) } export function sharedFileDownloadUrl(id: number): string { return `/api/shared-files/${id}` } /** RE 메모 한 건. 요구사항 [4]대로 메모 1개가 RE 1개다. */ export interface ReMemo { id: number orgId: number content: string author: string /** yyyy-MM-dd */ memoDate: string resolved: boolean createdAt: number } export interface ReMemoInput { content: string author: string memoDate: string resolved: boolean } export function getReMemos(orgId: number): Promise { return request(`/api/orgs/${orgId}/re-memos`) } export function addReMemo(orgId: number, body: ReMemoInput): Promise { return request(`/api/orgs/${orgId}/re-memos`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }) } export function updateReMemo(orgId: number, memoId: number, body: ReMemoInput): Promise { return request(`/api/orgs/${orgId}/re-memos/${memoId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }) } export async function deleteReMemo(orgId: number, memoId: number): Promise { await fetch(`/api/orgs/${orgId}/re-memos/${memoId}`, { method: 'DELETE', headers: { 'X-XSRF-TOKEN': csrfToken() }, credentials: 'same-origin', }) } export function updateAssignments(orgId: number, body: AssignmentInput): Promise { return request(`/api/orgs/${orgId}/assignments`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }) } export function getContacts(category?: ContactCategory): Promise { const query = category ? `?category=${category}` : '' return request(`/api/contacts${query}`) } export function createContact(body: ContactInput): Promise { return request('/api/contacts', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }) } export function updateContactInfo(id: number, body: ContactInput): Promise { return request(`/api/contacts/${id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }) } /** 204를 돌려주므로 request()의 json() 경로를 타지 않고 직접 부른다(deleteMemo()와 동일한 패턴). */ export async function deleteContact(id: number): Promise { const response = await fetch(`/api/contacts/${id}`, { method: 'DELETE', credentials: 'same-origin', headers: { 'X-XSRF-TOKEN': csrfToken() }, }) if (!response.ok) { const body = await response.text() throw new ApiError(response.status, `요청 실패 (${response.status}): ${body}`) } } export function provisionChannels(id: number): Promise { return request(`/api/orgs/${id}/channels`, { method: 'POST' }) } export interface StageHistoryEntry { stage: number changedAt: number } /** 업무단계 스트립의 단계별 도달 날짜 표시용. 최신순으로 온다. */ export function getStageHistory(orgId: number): Promise { return request(`/api/orgs/${orgId}/stage-history`) } export function updateStage(orgId: number, stage: number): Promise { return request(`/api/orgs/${orgId}/stage`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ stage }), }) } /** 대시보드 KPI 타일 7개. 모두 서버가 같은 원본에서 접어 만든 값이다. */ export interface DashboardSummary { applied: number docSubmitted: number reviewTotal: number reviewDone: number processTotal: number processDone: number /** RE 데이터 모델이 아직 없어 항상 0이다. */ unresolvedRe: number reportWriting: number completed: number } /** 대시보드 칸반 카드 1장 = "최근 진행 기관" 표 1줄. 둘이 같은 행을 쓴다. */ export interface DashboardOrgRow { id: number orgNo: string orgName: string stage: number | null /** false면 칸반의 "단계 미지정" 컬럼으로 간다. 드래그로 단계를 옮길 수 없다. */ hasChannel: boolean lawyerName: string | null mjName: string | null lawyerAssignedDate: string | null reviewTotal: number reviewDone: number processTotal: number processDone: number /** 현재 단계로 넘어온 시각(epoch ms). 카드의 D+n 계산용. */ stageChangedAt: number | null /** 단계·권리확인/처리·메모·배정 중 가장 최근 변동 시각(epoch ms). */ lastChangedAt: number | null reCount: number } export interface DashboardData { summary: DashboardSummary orgs: DashboardOrgRow[] rights?: { reviewResults: DistributionItem[] reviewSourceKoglTypes: DistributionItem[] reviewJudgedKoglTypes: DistributionItem[] processTypes: DistributionItem[] processStatuses: DistributionItem[] changedKoglCount: number } } export interface DistributionItem { label: string; count: number } export function getDashboard(): Promise { return request('/api/dashboard') } /** 사업관리 목록 1행. org는 대시보드와 같은 집계 행을 그대로 쓴다. */ export interface ProjectRow { org: DashboardOrgRow lastContactedOn: string | null contactCount: number reportCount: number } /** 신청 확인 연락 이력 1건. */ export interface ContactLog { id: number orgId: number contactedOn: string method: string summary: string author: string createdAt: number } export interface ContactLogInput { contactedOn: string method: string summary: string } /** 기관별 최종 보고서. */ export interface OrgReport { id: number orgId: number fileName: string contentType: string | null byteSize: number uploadedBy: string uploadedAt: number } export function getProjects(): Promise { return request('/api/projects') } export function getContactLogs(orgId: number): Promise { return request(`/api/orgs/${orgId}/contact-logs`) } /** 등록하면 갱신된 이력 전체가 돌아온다(작성자는 서버가 기록). */ export function addContactLog(orgId: number, body: ContactLogInput): Promise { return request(`/api/orgs/${orgId}/contact-logs`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }) } export function updateContactLog( orgId: number, logId: number, body: ContactLogInput, ): Promise { return request(`/api/orgs/${orgId}/contact-logs/${logId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }) } export async function deleteContactLog(orgId: number, logId: number): Promise { const response = await fetch(`/api/orgs/${orgId}/contact-logs/${logId}`, { method: 'DELETE', credentials: 'same-origin', headers: { 'X-XSRF-TOKEN': csrfToken() }, }) if (!response.ok) { throw new ApiError(response.status, `요청 실패 (${response.status})`) } } export function getReports(orgId: number): Promise { return request(`/api/orgs/${orgId}/reports`) } export function uploadReport(orgId: number, file: File): Promise { const form = new FormData() form.append('file', file) return request(`/api/orgs/${orgId}/reports`, { method: 'POST', body: form }) } /** 다운로드는 가 그대로 열게 둔다. */ export function reportDownloadUrl(orgId: number, reportId: number): string { return `/api/orgs/${orgId}/reports/${reportId}` } export async function deleteReport(orgId: number, reportId: number): Promise { const response = await fetch(`/api/orgs/${orgId}/reports/${reportId}`, { method: 'DELETE', credentials: 'same-origin', headers: { 'X-XSRF-TOKEN': csrfToken() }, }) if (!response.ok) { throw new ApiError(response.status, `요청 실패 (${response.status})`) } } /** 시스템관리 > 채널 초기화 미리보기 1행. 웹앱 DB가 아는 채널만 담긴다. */ export interface ResetTarget { orgId: number orgNo: string orgName: string stage: number | null channels: { kind: string; channelId: string }[] } export interface ResetReport { archivedChannels: number clearedOrgs: number failures: string[] } /** 실행 확인 문구. 서버도 같은 값을 검사한다. */ export const RESET_CONFIRM_PHRASE = '채널 초기화' export function getResetPreview(): Promise { return request('/api/system/reset/preview') } export function resetChannels(confirm: string): Promise { return request('/api/system/reset/channels', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ confirm }), }) } export function getTimeline(orgId: number): Promise { return request(`/api/orgs/${orgId}/timeline`) } export function getPosts(orgId: number, channel: ChannelKindKey): Promise { return request(`/api/orgs/${orgId}/posts?channel=${channel}`) } export function getFiles(orgId: number, channel: ChannelKindKey): Promise { return request(`/api/orgs/${orgId}/files?channel=${channel}`) } /** 다운로드는 가 그대로 열게 두므로 fetch()를 거치지 않는다. */ export function fileDownloadUrl(fileId: string): string { return `/api/files/${fileId}` } export interface SendResult { post: PostView /** 게시글은 전송됐지만 공지 뒤처리(핀/헤더)가 일부 실패한 경우의 경고. 성공이면 null */ noticeWarning: string | null } export function sendMessage( orgId: number, channel: ChannelKindKey, body: { message: string; notice: boolean; files: File[] }, ): Promise { const form = new FormData() form.append('channel', channel) form.append('message', body.message) form.append('notice', String(body.notice)) for (const file of body.files) { form.append('files', file) } return request(`/api/orgs/${orgId}/messages`, { method: 'POST', body: form }) } /** 공지가 없으면 서버가 204를 주므로 request()의 json() 경로를 타지 않고 직접 부른다. */ export async function getNotice(orgId: number, channel: ChannelKindKey): Promise { const response = await fetch(`/api/orgs/${orgId}/notice?channel=${channel}`, { credentials: 'same-origin', }) if (response.status === 204) { return null } if (!response.ok) { throw new ApiError(response.status, `요청 실패 (${response.status})`) } return response.json() as Promise } export async function clearNotice(orgId: number, channel: ChannelKindKey): Promise { const response = await fetch(`/api/orgs/${orgId}/notice?channel=${channel}`, { method: 'DELETE', credentials: 'same-origin', headers: { 'X-XSRF-TOKEN': csrfToken() }, }) if (!response.ok) { throw new ApiError(response.status, `공지 해제 실패 (${response.status})`) } } export function getMemos(orgId: number): Promise { return request(`/api/orgs/${orgId}/memos`) } /** 작성자는 보내지 않는다 - 서버가 로그인 사용자로 기록한다. */ export function createMemo(orgId: number, memo: { body: string }): Promise { return request(`/api/orgs/${orgId}/memos`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(memo), }) } /** 204를 돌려주므로 request()의 json() 경로를 타지 않고 직접 부른다(logout()과 동일한 패턴). */ /** 본문만 고친다. 작성자·작성시각은 서버가 그대로 유지한다. */ export function updateMemo(orgId: number, memoId: number, memo: { body: string }): Promise { return request(`/api/orgs/${orgId}/memos/${memoId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(memo), }) } export async function deleteMemo(orgId: number, memoId: number): Promise { const response = await fetch(`/api/orgs/${orgId}/memos/${memoId}`, { method: 'DELETE', credentials: 'same-origin', headers: { 'X-XSRF-TOKEN': csrfToken() }, }) if (!response.ok) { const body = await response.text() throw new ApiError(response.status, `요청 실패 (${response.status}): ${body}`) } } /** 권리확인 탭 게시물 한 건. W열은 원본 시트의 빈 열이라 대응 필드가 없다. */ export interface ReviewItem { id: number orgId: number seq: number siteName: string | null category: string | null boardPath: string | null boardName: string | null postTitle: string | null url: string | null postRegistered: string | null producedDate: string | null publishedDate: string | null hasAttachment: string | null koglAttached: string | null koglType: string | null aiType: string | null surveyorNote: string | null openable: string | null reviewMajor: string | null reviewMinor: string | null reviewResult: string | null judgedKoglType: string | null judgedAiType: string | null opinion: string | null lawyerNote: string | null needsProcessing: string | null webEditedAt: number | null createdAt: number updatedAt: number } /** total은 현재 keyword 필터 기준 건수(페이지 계산용), done은 검색어와 무관한 기관 전체 처리 건수다. */ export interface ReviewPage { items: ReviewItem[] total: number done: number page: number size: number } export interface ReviewImportReport { created: number updated: number total: number } /** 권리확인 상세의 [수정] 저장 요청. 전부 선택값이다. */ export interface JudgmentRequest { hasAttachment: string | null koglAttached: string | null koglType: string | null aiType: string | null openable: string | null reviewMajor: string | null reviewMinor: string | null reviewResult: string | null judgedKoglType: string | null judgedAiType: string | null opinion: string | null lawyerNote: string | null needsProcessing: string | null } export function importReview(orgId: number, file: File): Promise { const form = new FormData() form.append('file', file) return request(`/api/orgs/${orgId}/review/import`, { method: 'POST', body: form }) } /** 검색 조건. keyword는 통합검색, site/board/title은 열별 조건으로 서로 AND로 겹친다. */ export interface ItemSearch { keyword?: string site?: string board?: string title?: string } export function getReviewPage( orgId: number, params: ItemSearch & { page?: number; size?: number } = {}, ): Promise { const query = new URLSearchParams() if (params.keyword) { query.set('keyword', params.keyword) } for (const field of ['site', 'board', 'title'] as const) { if (params[field]) { query.set(field, params[field] as string) } } if (params.page !== undefined) { query.set('page', String(params.page)) } if (params.size !== undefined) { query.set('size', String(params.size)) } const qs = query.toString() return request(`/api/orgs/${orgId}/review${qs ? `?${qs}` : ''}`) } export function getReviewItem(orgId: number, itemId: number): Promise { return request(`/api/orgs/${orgId}/review/${itemId}`) } export function updateReviewJudgment( orgId: number, itemId: number, body: JudgmentRequest, ): Promise { return request(`/api/orgs/${orgId}/review/${itemId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }) } /** 204를 돌려주므로 request()의 json() 경로를 타지 않고 직접 부른다. */ export async function deleteReviewItem(orgId: number, itemId: number): Promise { const response = await fetch(`/api/orgs/${orgId}/review/${itemId}`, { method: 'DELETE', credentials: 'same-origin', headers: { 'X-XSRF-TOKEN': csrfToken() }, }) if (!response.ok) { const body = await response.text() throw new ApiError(response.status, `요청 실패 (${response.status}): ${body}`) } } /** 다운로드는 가 그대로 열게 두므로 fetch()를 거치지 않는다. */ export function reviewDownloadUrl(orgId: number): string { return `/api/orgs/${orgId}/review/download` } /** 권리처리 탭 게시물 한 건. U(수정 링크)/Y(빈 열)열은 원본 시트의 건너뛰는 열이라 대응 필드가 없다. */ export interface ProcessItem { id: number orgId: number seq: number siteName: string | null category: string | null boardName: string | null postTitle: string | null url: string | null description: string | null hasAttachment: string | null priorKoglType: string | null contractDocs: string | null producedDate: string | null publishedDate: string | null reviewMajor: string | null reviewMinor: string | null reviewResult: string | null reviewKoglType: string | null reviewAiType: string | null reviewOpinion: string | null reviewNote: string | null priorEvidence: string | null judgedKoglType: string | null judgedAiType: string | null finalOpinion: string | null judgmentBasis: string | null processStatus: string | null webEditedAt: number | null processedAt: number | null createdAt: number updatedAt: number } export type ProcessStatusFilter = 'DONE' | 'PENDING' /** total/done은 검색어·상태 필터와 무관한 기관 전체 진행률이다(헤더 "총 n건 · 처리완료 m건"용). */ export interface ProcessPage { items: ProcessItem[] total: number done: number page: number size: number } export interface ProcessImportReport { created: number updated: number total: number } /** 권리처리 상세의 [처리등록]/[수정] 저장 요청. 전부 선택값이다. */ export interface ProcessingRequest { contractDocs: string | null judgedKoglType: string | null judgedAiType: string | null finalOpinion: string | null judgmentBasis: string | null processStatus: string | null } export function importProcess(orgId: number, file: File): Promise { const form = new FormData() form.append('file', file) return request(`/api/orgs/${orgId}/process/import`, { method: 'POST', body: form }) } /** 권리확인 일괄 등록. 비운 항목은 기존 값을 유지한다. */ export function bulkUpdateReview( orgId: number, body: { ids: number[] reviewResult?: string judgedKoglType?: string opinion?: string /** 요구사항 [15]로 추가된 비고. */ lawyerNote?: string }, ): Promise<{ updated: number }> { return request<{ updated: number }>(`/api/orgs/${orgId}/review/bulk`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }) } export function bulkDeleteReview(orgId: number, ids: number[]): Promise<{ deleted: number }> { return request<{ deleted: number }>(`/api/orgs/${orgId}/review/bulk-delete`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(ids), }) } /** 권리처리 일괄 등록. 비운 항목은 기존 값을 유지한다. */ export function bulkUpdateProcess( orgId: number, body: { ids: number[] processStatus?: string judgedKoglType?: string finalOpinion?: string /** 요구사항 [15]로 추가된 비고. */ reviewNote?: string }, ): Promise<{ updated: number }> { return request<{ updated: number }>(`/api/orgs/${orgId}/process/bulk`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }) } export function bulkDeleteProcess(orgId: number, ids: number[]): Promise<{ deleted: number }> { return request<{ deleted: number }>(`/api/orgs/${orgId}/process/bulk-delete`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(ids), }) } export function getProcessPage( orgId: number, params: ItemSearch & { status?: ProcessStatusFilter; page?: number; size?: number } = {}, ): Promise { const query = new URLSearchParams() if (params.keyword) { query.set('keyword', params.keyword) } for (const field of ['site', 'board', 'title'] as const) { if (params[field]) { query.set(field, params[field] as string) } } if (params.status) { query.set('status', params.status) } if (params.page !== undefined) { query.set('page', String(params.page)) } if (params.size !== undefined) { query.set('size', String(params.size)) } const qs = query.toString() return request(`/api/orgs/${orgId}/process${qs ? `?${qs}` : ''}`) } export function getProcessItem(orgId: number, itemId: number): Promise { return request(`/api/orgs/${orgId}/process/${itemId}`) } export function updateProcessing( orgId: number, itemId: number, body: ProcessingRequest, ): Promise { return request(`/api/orgs/${orgId}/process/${itemId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }) } /** 204를 돌려주므로 request()의 json() 경로를 타지 않고 직접 부른다. */ export async function deleteProcessItem(orgId: number, itemId: number): Promise { const response = await fetch(`/api/orgs/${orgId}/process/${itemId}`, { method: 'DELETE', credentials: 'same-origin', headers: { 'X-XSRF-TOKEN': csrfToken() }, }) if (!response.ok) { const body = await response.text() throw new ApiError(response.status, `요청 실패 (${response.status}): ${body}`) } } /** 다운로드는 가 그대로 열게 두므로 fetch()를 거치지 않는다. */ export function processDownloadUrl(orgId: number): string { return `/api/orgs/${orgId}/process/download` } export function uploadSeed(file: File): Promise { const form = new FormData() form.append('file', file) return request('/api/seed', { method: 'POST', body: form }) } export interface MemberImportReport { created: number updated: number skipped: number assigned: number } export function importMembers(file: File): Promise { const form = new FormData() form.append('file', file) return request('/api/contacts/import', { method: 'POST', body: form }) } /** 204를 돌려주므로 request()의 json() 경로를 타지 않고 직접 부른다. */ export async function logout(): Promise { const response = await fetch('/api/auth/logout', { method: 'POST', credentials: 'same-origin', headers: { 'X-XSRF-TOKEN': csrfToken() }, }) if (!response.ok) { throw new ApiError(response.status, `로그아웃 실패 (${response.status})`) } } export async function login(username: string, password: string): Promise { const body = new URLSearchParams({ username, password }) const response = await fetch('/api/auth/login', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-XSRF-TOKEN': csrfToken(), }, body, }) if (!response.ok) { throw new ApiError(response.status, '아이디 또는 비밀번호가 올바르지 않습니다.') } }