/** * 문서 작성 서버 액션 * * API Endpoints: * - GET /api/v1/reports/expense-estimate - 비용견적서 항목 조회 * - GET /api/v1/employees - 직원 목록 (결재선/참조 선택용) * - POST /api/v1/approvals - 결재 문서 생성 (임시저장) * - POST /api/v1/approvals/{id}/submit - 결재 문서 상신 */ 'use server'; import { isNextRedirectError } from '@/lib/utils/redirect-error'; import { cookies } from 'next/headers'; import { executeServerAction } from '@/lib/api/execute-server-action'; import { buildApiUrl } from '@/lib/api/query-params'; import type { ExpenseEstimateItem, ApprovalPerson, DocumentFormData, UploadedFile, } from './types'; // ============================================ // API 응답 타입 정의 // ============================================ // 비용견적서 API 응답 타입 interface ExpenseEstimateApiItem { id: number; expected_payment_date: string; category: string; amount: number; vendor: string; account_info?: string; memo?: string; } interface ExpenseEstimateApiResponse { year_month: string; items: ExpenseEstimateApiItem[]; total_expense: number; account_balance: number; final_difference: number; } // 직원 API 응답 타입 (TenantUserProfile 구조) interface EmployeeApiData { id: number; // TenantUserProfile.id user_id: number; // User.id (결재선에 사용) position_key?: string; // 직책 코드 (EXECUTIVE, DIRECTOR 등) user?: { id: number; name: string; email?: string; }; department?: { id: number; name: string; }; } // 결재 문서 생성 응답 타입 interface ApprovalCreateResponse { id: number; document_number: string; status: string; } // ============================================ // 헬퍼 함수 // ============================================ /** * 비용견적서 API 데이터 → 프론트엔드 데이터 변환 */ function transformExpenseEstimateItem(item: ExpenseEstimateApiItem): ExpenseEstimateItem { return { id: String(item.id), checked: false, expectedPaymentDate: item.expected_payment_date, category: item.category, amount: item.amount, vendor: item.vendor, memo: item.account_info || item.memo || '', }; } /** * 직책 코드를 한글 라벨로 변환 (직원 목록용) */ function getPositionLabelForEmployee(positionKey: string | null | undefined): string { if (!positionKey) return ''; const labels: Record = { 'EXECUTIVE': '임원', 'DIRECTOR': '부장', 'MANAGER': '과장', 'SENIOR': '대리', 'STAFF': '사원', 'INTERN': '인턴', }; return labels[positionKey] ?? positionKey; } /** * 직원 API 데이터 → 결재자 데이터 변환 * API는 TenantUserProfile 구조를 반환함 */ function transformEmployee(employee: EmployeeApiData): ApprovalPerson { return { id: String(employee.user?.id || employee.user_id), // User.id 사용 (결재선에 필요) name: employee.user?.name || '', position: getPositionLabelForEmployee(employee.position_key), department: employee.department?.name || '', }; } // ============================================ // API 함수 // ============================================ /** * 파일 업로드 * @param files 업로드할 파일 배열 * @returns 업로드된 파일 정보 배열 * * NOTE: 파일 업로드는 multipart/form-data가 필요하므로 serverFetch 대신 직접 fetch 사용 */ export async function uploadFiles(files: File[]): Promise<{ success: boolean; data?: UploadedFile[]; error?: string; }> { if (files.length === 0) { return { success: true, data: [] }; } try { const cookieStore = await cookies(); const token = cookieStore.get('access_token')?.value; const uploadedFiles: UploadedFile[] = []; // 파일을 하나씩 업로드 (멀티파트 폼) for (const file of files) { const formData = new FormData(); formData.append('file', file); const response = await fetch( `${process.env.NEXT_PUBLIC_API_URL}/api/v1/files/upload`, { method: 'POST', headers: { 'Authorization': token ? `Bearer ${token}` : '', 'X-API-KEY': process.env.API_KEY || '', // Content-Type은 자동 설정됨 (multipart/form-data) }, body: formData, } ); if (!response.ok) { console.error('[DocumentCreateActions] File upload error:', response.status); return { success: false, error: `파일 업로드 실패: ${file.name}` }; } const result = await response.json(); if (result.success && result.data) { // API 응답 필드: id, display_name, file_path, file_size, mime_type uploadedFiles.push({ id: result.data.id, name: result.data.display_name || file.name, url: `/api/proxy/files/${result.data.id}/download`, size: result.data.file_size, mime_type: result.data.mime_type, }); } } return { success: true, data: uploadedFiles }; } catch (error) { if (isNextRedirectError(error)) throw error; console.error('[DocumentCreateActions] uploadFiles error:', error); return { success: false, error: '파일 업로드 중 오류가 발생했습니다.' }; } } /** * 비용견적서 항목 조회 */ export async function getExpenseEstimateItems(yearMonth?: string): Promise<{ items: ExpenseEstimateItem[]; totalExpense: number; accountBalance: number; finalDifference: number; } | null> { const result = await executeServerAction({ url: buildApiUrl('/api/v1/reports/expense-estimate', { year_month: yearMonth }), errorMessage: '비용견적서 조회에 실패했습니다.', }); if (!result.success || !result.data) return null; return { items: result.data.items.map(transformExpenseEstimateItem), totalExpense: result.data.total_expense, accountBalance: result.data.account_balance, finalDifference: result.data.final_difference, }; } /** * 직원 목록 조회 (결재선/참조 선택용) */ export async function getEmployees(search?: string): Promise { const result = await executeServerAction<{ data: EmployeeApiData[] }>({ url: buildApiUrl('/api/v1/employees', { per_page: 100, search }), errorMessage: '직원 목록 조회에 실패했습니다.', }); if (!result.success || !result.data?.data) return []; return result.data.data.map(transformEmployee); } /** * 결재 문서 생성 (임시저장) */ export async function createApproval(formData: DocumentFormData): Promise<{ success: boolean; data?: { id: number; documentNo: string }; error?: string; }> { // 새 첨부파일 업로드 const newFiles = formData.proposalData?.attachments || formData.expenseReportData?.attachments || []; let uploadedFiles: UploadedFile[] = []; if (newFiles.length > 0) { const uploadResult = await uploadFiles(newFiles); if (!uploadResult.success) { return { success: false, error: uploadResult.error }; } uploadedFiles = uploadResult.data || []; } const requestBody = { form_code: formData.basicInfo.documentType, title: getDocumentTitle(formData), status: 'draft', steps: [ ...formData.approvalLine.map((person, index) => ({ step_type: 'approval', step_order: index + 1, approver_id: parseInt(person.id), })), ...formData.references.map((person, index) => ({ step_type: 'reference', step_order: formData.approvalLine.length + index + 1, approver_id: parseInt(person.id), })), ], content: getDocumentContent(formData, uploadedFiles), }; const result = await executeServerAction({ url: buildApiUrl('/api/v1/approvals'), method: 'POST', body: requestBody, errorMessage: '문서 저장에 실패했습니다.', }); if (!result.success || !result.data) return { success: false, error: result.error }; return { success: true, data: { id: result.data.id, documentNo: result.data.document_number } }; } /** * 결재 문서 상신 */ export async function submitApproval(id: number): Promise<{ success: boolean; error?: string; }> { const result = await executeServerAction({ url: buildApiUrl(`/api/v1/approvals/${id}/submit`), method: 'POST', body: {}, errorMessage: '문서 상신에 실패했습니다.', }); return { success: result.success, error: result.error }; } /** * 결재 문서 생성 및 상신 (한번에) */ export async function createAndSubmitApproval(formData: DocumentFormData): Promise<{ success: boolean; data?: { id: number; documentNo: string }; error?: string; }> { try { // 1. 먼저 문서 생성 const createResult = await createApproval(formData); if (!createResult.success || !createResult.data) { return createResult; } // 2. 상신 const submitResult = await submitApproval(createResult.data.id); if (!submitResult.success) { return { success: false, error: submitResult.error || '문서 상신에 실패했습니다.', }; } return { success: true, data: createResult.data, }; } catch (error) { if (isNextRedirectError(error)) throw error; console.error('[DocumentCreateActions] createAndSubmitApproval error:', error); return { success: false, error: '서버 오류가 발생했습니다.', }; } } /** * 결재 문서 조회 (수정 모드용) */ export async function getApprovalById(id: number): Promise<{ success: boolean; data?: DocumentFormData; error?: string; }> { // eslint-disable-next-line @typescript-eslint/no-explicit-any const result = await executeServerAction({ url: buildApiUrl(`/api/v1/approvals/${id}`), errorMessage: '문서 조회에 실패했습니다.', }); if (!result.success || !result.data) return { success: false, error: result.error }; return { success: true, data: transformApiToFormData(result.data) }; } /** * 결재 문서 수정 */ export async function updateApproval(id: number, formData: DocumentFormData): Promise<{ success: boolean; data?: { id: number; documentNo: string }; error?: string; }> { // 새 첨부파일 업로드 const newFiles = formData.proposalData?.attachments || formData.expenseReportData?.attachments || []; let uploadedFiles: UploadedFile[] = []; if (newFiles.length > 0) { const uploadResult = await uploadFiles(newFiles); if (!uploadResult.success) { return { success: false, error: uploadResult.error }; } uploadedFiles = uploadResult.data || []; } const requestBody = { form_code: formData.basicInfo.documentType, title: getDocumentTitle(formData), steps: [ ...formData.approvalLine.map((person, index) => ({ step_type: 'approval', step_order: index + 1, approver_id: parseInt(person.id), })), ...formData.references.map((person, index) => ({ step_type: 'reference', step_order: formData.approvalLine.length + index + 1, approver_id: parseInt(person.id), })), ], content: getDocumentContent(formData, uploadedFiles), }; const result = await executeServerAction({ url: buildApiUrl(`/api/v1/approvals/${id}`), method: 'PATCH', body: requestBody, errorMessage: '문서 수정에 실패했습니다.', }); if (!result.success || !result.data) return { success: false, error: result.error }; return { success: true, data: { id: result.data.id, documentNo: result.data.document_number } }; } /** * 결재 문서 수정 및 상신 */ export async function updateAndSubmitApproval(id: number, formData: DocumentFormData): Promise<{ success: boolean; data?: { id: number; documentNo: string }; error?: string; }> { try { // 1. 먼저 문서 수정 const updateResult = await updateApproval(id, formData); if (!updateResult.success) { return updateResult; } // 2. 상신 const submitResult = await submitApproval(id); if (!submitResult.success) { return { success: false, error: submitResult.error || '문서 상신에 실패했습니다.', }; } return { success: true, data: updateResult.data, }; } catch (error) { if (isNextRedirectError(error)) throw error; console.error('[DocumentCreateActions] updateAndSubmitApproval error:', error); return { success: false, error: '서버 오류가 발생했습니다.' }; } } /** * 결재 문서 삭제 */ export async function deleteApproval(id: number): Promise<{ success: boolean; error?: string; }> { const result = await executeServerAction({ url: buildApiUrl(`/api/v1/approvals/${id}`), method: 'DELETE', errorMessage: '문서 삭제에 실패했습니다.', }); return { success: result.success, error: result.error }; } // ============================================ // 내부 헬퍼 함수 // ============================================ /** * 문서 제목 생성 */ function getDocumentTitle(formData: DocumentFormData): string { switch (formData.basicInfo.documentType) { case 'proposal': return formData.proposalData?.title || '품의서'; case 'expenseReport': return `지출결의서 - ${formData.expenseReportData?.requestDate || ''}`; case 'expenseEstimate': return `지출 예상 내역서`; default: return '문서'; } } /** * 직책 코드를 한글 라벨로 변환 */ function getPositionLabel(positionKey: string | null | undefined): string { if (!positionKey) return ''; const labels: Record = { 'EXECUTIVE': '임원', 'DIRECTOR': '부장', 'MANAGER': '과장', 'SENIOR': '대리', 'STAFF': '사원', 'INTERN': '인턴', }; return labels[positionKey] ?? positionKey; } /** * API 응답을 프론트엔드 폼 데이터로 변환 */ function transformApiToFormData(apiData: { id: number; document_number: string; form_code?: string; // 이전 호환성 form?: { // 현재 API 구조 id: number; name: string; code: string; category?: string; template?: Record; }; title: string; status: string; content: Record; steps?: Array<{ step_type: string; approver_id: number; approver?: { id: number; name: string; position?: string; department?: { name: string }; tenant_profile?: { position_key?: string; display_name?: string; department?: { name: string }; }; }; }>; created_at: string; requester?: { name: string; }; drafter?: { id: number; name: string; tenant_profile?: { position_key?: string; display_name?: string; department?: { name: string }; }; }; }): DocumentFormData { // form.code를 우선 사용, 없으면 form_code (이전 호환성) const formCode = apiData.form?.code || apiData.form_code || 'proposal'; const documentType = formCode as 'proposal' | 'expenseReport' | 'expenseEstimate'; const content = apiData.content || {}; // 결재선 및 참조자 분리 const approvalLine: ApprovalPerson[] = []; const references: ApprovalPerson[] = []; if (apiData.steps) { for (const step of apiData.steps) { if (step.approver) { // tenantProfile에서 직책/부서 정보 추출 (우선), 없으면 기존 필드 사용 const tenantProfile = step.approver.tenant_profile; const position = tenantProfile?.position_key ? getPositionLabel(tenantProfile.position_key) : (step.approver.position || ''); const department = tenantProfile?.department?.name || step.approver.department?.name || ''; const person: ApprovalPerson = { id: String(step.approver.id), name: step.approver.name, position, department, }; // 'approval'과 'agreement' 모두 결재선에 포함 if (step.step_type === 'approval' || step.step_type === 'agreement') { approvalLine.push(person); } else if (step.step_type === 'reference') { references.push(person); } } } } // 기본 정보 (drafter에서 tenantProfile 정보 추출) const drafterProfile = apiData.drafter?.tenant_profile; const basicInfo = { drafter: apiData.drafter?.name || apiData.requester?.name || '', drafterPosition: drafterProfile?.position_key ? getPositionLabel(drafterProfile.position_key) : '', drafterDepartment: drafterProfile?.department?.name || '', draftDate: apiData.created_at, documentNo: apiData.document_number, documentType, }; // 기존 업로드 파일 추출 const existingFiles = (content.files as Array<{ id: number; name: string; url?: string; size?: number; mime_type?: string; }>) || []; const uploadedFiles: UploadedFile[] = existingFiles.map(f => ({ id: f.id, name: f.name, // URL이 없거나 상대 경로인 경우 다운로드 URL 생성 url: f.url?.startsWith('http') ? f.url : `/api/proxy/files/${f.id}/download`, size: f.size, mime_type: f.mime_type, })); // 문서 유형별 데이터 변환 let proposalData; let expenseReportData; let expenseEstimateData; if (documentType === 'proposal') { proposalData = { vendorId: (content.vendorId as string) || '', vendor: (content.vendor as string) || '', vendorPaymentDate: (content.vendorPaymentDate as string) || '', title: (content.title as string) || '', description: (content.description as string) || '', reason: (content.reason as string) || '', estimatedCost: (content.estimatedCost as number) || 0, attachments: [], uploadedFiles, }; } else if (documentType === 'expenseReport') { const items = (content.items as Array<{ id: string; description: string; amount: number; note?: string; }>) || []; expenseReportData = { requestDate: (content.requestDate as string) || '', paymentDate: (content.paymentDate as string) || '', items: items.map(item => ({ id: item.id, description: item.description, amount: item.amount, note: item.note || '', })), cardId: (content.cardId as string) || '', totalAmount: (content.totalAmount as number) || 0, attachments: [], uploadedFiles, }; } else if (documentType === 'expenseEstimate') { const items = (content.items as Array<{ id: string; checked: boolean; expectedPaymentDate: string; category: string; amount: number; vendor: string; memo?: string; }>) || []; expenseEstimateData = { items: items.map(item => ({ id: item.id, checked: item.checked || false, expectedPaymentDate: item.expectedPaymentDate, category: item.category, amount: item.amount, vendor: item.vendor, memo: item.memo || '', })), totalExpense: (content.totalExpense as number) || 0, accountBalance: (content.accountBalance as number) || 0, finalDifference: (content.finalDifference as number) || 0, }; } return { basicInfo, approvalLine, references, proposalData, expenseReportData, expenseEstimateData, }; } /** * 문서 내용 생성 (JSON) * @param formData 폼 데이터 * @param uploadedFiles 새로 업로드된 파일 목록 */ function getDocumentContent( formData: DocumentFormData, uploadedFiles: UploadedFile[] = [] ): Record { // 기존 업로드 파일 + 새로 업로드된 파일 합치기 const existingFiles = formData.proposalData?.uploadedFiles || formData.expenseReportData?.uploadedFiles || []; const allFiles = [...existingFiles, ...uploadedFiles]; switch (formData.basicInfo.documentType) { case 'proposal': return { vendorId: formData.proposalData?.vendorId, vendor: formData.proposalData?.vendor, vendorPaymentDate: formData.proposalData?.vendorPaymentDate, title: formData.proposalData?.title, description: formData.proposalData?.description, reason: formData.proposalData?.reason, estimatedCost: formData.proposalData?.estimatedCost, files: allFiles, }; case 'expenseReport': return { requestDate: formData.expenseReportData?.requestDate, paymentDate: formData.expenseReportData?.paymentDate, items: formData.expenseReportData?.items, cardId: formData.expenseReportData?.cardId, totalAmount: formData.expenseReportData?.totalAmount, files: allFiles, }; case 'expenseEstimate': return { items: formData.expenseEstimateData?.items, totalExpense: formData.expenseEstimateData?.totalExpense, accountBalance: formData.expenseEstimateData?.accountBalance, finalDifference: formData.expenseEstimateData?.finalDifference, }; default: return {}; } }