/** * 기안함 서버 액션 * * API Endpoints: * - GET /api/v1/approvals/drafts - 기안함 목록 조회 * - GET /api/v1/approvals/drafts/summary - 기안함 현황 카드 * - GET /api/v1/approvals/{id} - 결재 문서 상세 * - DELETE /api/v1/approvals/{id} - 결재 문서 삭제 (임시저장만) * - POST /api/v1/approvals/{id}/submit - 결재 상신 * - POST /api/v1/approvals/{id}/cancel - 결재 회수 */ 'use server'; import { isNextRedirectError } from '@/lib/utils/redirect-error'; import { serverFetch } from '@/lib/api/fetch-wrapper'; import type { DraftRecord, DocumentStatus, Approver } from './types'; // ============================================ // API 응답 타입 정의 // ============================================ interface ApiResponse { success: boolean; data: T; message: string; } interface PaginatedResponse { current_page: number; data: T[]; total: number; per_page: number; last_page: number; } interface DraftsSummary { total: number; draft: number; pending: number; approved: number; rejected: number; } // API 응답의 결재 문서 타입 interface ApprovalApiData { id: number; document_number: string; title: string; status: string; form?: { id: number; name: string; code: string; category: string; }; drafter?: { id: number; name: string; }; steps?: ApprovalStepApiData[]; content?: Record; created_at: string; updated_at: string; } interface ApprovalStepApiData { id: number; step_order: number; step_type: string; approver_id: number; approver?: { id: number; name: string; tenant_profile?: { position_key?: string; department?: { id: number; name: string }; }; }; status: string; processed_at?: string; comment?: string; } // ============================================ // 헬퍼 함수 // ============================================ /** * API 상태 → 프론트엔드 상태 변환 */ function mapApiStatus(apiStatus: string): DocumentStatus { const statusMap: Record = { 'draft': 'draft', 'pending': 'pending', 'in_progress': 'inProgress', 'approved': 'approved', 'rejected': 'rejected', }; return statusMap[apiStatus] || 'draft'; } /** * 결재자 상태 변환 */ function mapApproverStatus(stepStatus: string): Approver['status'] { const statusMap: Record = { 'pending': 'pending', 'approved': 'approved', 'rejected': 'rejected', }; return statusMap[stepStatus] || 'none'; } /** * 직책 코드 → 한글 변환 */ 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 transformApiToFrontend(data: ApprovalApiData): DraftRecord { // approval 타입 결재자만 필터링 (reference 제외) const approvers: Approver[] = (data.steps || []) .filter((step) => step.step_type === 'approval') .map((step) => ({ id: String(step.approver_id), name: step.approver?.name || '', position: getPositionLabel(step.approver?.tenant_profile?.position_key), department: step.approver?.tenant_profile?.department?.name || '', status: mapApproverStatus(step.status), approvedAt: step.processed_at, })); // drafter의 tenant_profile에서 직책/부서 추출 const drafterProfile = (data.drafter as { tenant_profile?: { position_key?: string; department?: { name: string } } })?.tenant_profile; return { id: String(data.id), documentNo: data.document_number, documentType: data.form?.name || '', documentTypeCode: data.form?.code || 'proposal', title: data.title, draftDate: data.created_at.split('T')[0], drafter: data.drafter?.name || '', drafterPosition: getPositionLabel(drafterProfile?.position_key), drafterDepartment: drafterProfile?.department?.name || '', approvers, status: mapApiStatus(data.status), content: data.content, createdAt: data.created_at, updatedAt: data.updated_at, }; } // ============================================ // API 함수 // ============================================ /** * 기안함 목록 조회 */ export async function getDrafts(params?: { page?: number; per_page?: number; search?: string; status?: string; sort_by?: string; sort_dir?: 'asc' | 'desc'; }): Promise<{ data: DraftRecord[]; total: number; lastPage: number; __authError?: boolean }> { try { const searchParams = new URLSearchParams(); if (params?.page) searchParams.set('page', String(params.page)); if (params?.per_page) searchParams.set('per_page', String(params.per_page)); if (params?.search) searchParams.set('search', params.search); if (params?.status && params.status !== 'all') { // 프론트엔드 상태 → API 상태 const statusMap: Record = { 'draft': 'draft', 'pending': 'pending', 'inProgress': 'in_progress', 'approved': 'approved', 'rejected': 'rejected', }; searchParams.set('status', statusMap[params.status] || params.status); } if (params?.sort_by) searchParams.set('sort_by', params.sort_by); if (params?.sort_dir) searchParams.set('sort_dir', params.sort_dir); const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/approvals/drafts?${searchParams.toString()}`; const { response, error } = await serverFetch(url, { method: 'GET' }); if (error?.__authError) { return { data: [], total: 0, lastPage: 1, __authError: true }; } if (!response) { console.error('[DraftBoxActions] GET drafts error:', error?.message); return { data: [], total: 0, lastPage: 1 }; } const result: ApiResponse> = await response.json(); if (!response.ok || !result.success || !result.data?.data) { console.warn('[DraftBoxActions] No data in response'); return { data: [], total: 0, lastPage: 1 }; } return { data: result.data.data.map(transformApiToFrontend), total: result.data.total, lastPage: result.data.last_page, }; } catch (error) { if (isNextRedirectError(error)) throw error; console.error('[DraftBoxActions] getDrafts error:', error); return { data: [], total: 0, lastPage: 1 }; } } /** * 기안함 현황 카드 (통계) */ export async function getDraftsSummary(): Promise { try { const { response, error } = await serverFetch( `${process.env.NEXT_PUBLIC_API_URL}/api/v1/approvals/drafts/summary`, { method: 'GET' } ); if (error?.__authError || !response) { console.error('[DraftBoxActions] GET summary error:', error?.message); return null; } const result: ApiResponse = await response.json(); if (!response.ok || !result.success || !result.data) { return null; } return result.data; } catch (error) { if (isNextRedirectError(error)) throw error; console.error('[DraftBoxActions] getDraftsSummary error:', error); return null; } } /** * 결재 문서 상세 조회 */ export async function getDraftById(id: string): Promise { try { const { response, error } = await serverFetch( `${process.env.NEXT_PUBLIC_API_URL}/api/v1/approvals/${id}`, { method: 'GET' } ); if (error?.__authError || !response) { console.error('[DraftBoxActions] GET draft error:', error?.message); return null; } const result: ApiResponse = await response.json(); if (!response.ok || !result.success || !result.data) { return null; } return transformApiToFrontend(result.data); } catch (error) { if (isNextRedirectError(error)) throw error; console.error('[DraftBoxActions] getDraftById error:', error); return null; } } /** * 결재 문서 삭제 (임시저장 상태만) */ export async function deleteDraft(id: string): Promise<{ success: boolean; error?: string; __authError?: boolean }> { try { const { response, error } = await serverFetch( `${process.env.NEXT_PUBLIC_API_URL}/api/v1/approvals/${id}`, { method: 'DELETE' } ); if (error?.__authError) { return { success: false, __authError: true }; } if (!response) { return { success: false, error: error?.message || '결재 문서 삭제에 실패했습니다.' }; } const result = await response.json(); if (!response.ok || !result.success) { return { success: false, error: result.message || '결재 문서 삭제에 실패했습니다.', }; } return { success: true }; } catch (error) { if (isNextRedirectError(error)) throw error; console.error('[DraftBoxActions] deleteDraft error:', error); return { success: false, error: '서버 오류가 발생했습니다.', }; } } /** * 결재 문서 일괄 삭제 */ export async function deleteDrafts(ids: string[]): Promise<{ success: boolean; failedIds?: string[]; error?: string }> { const failedIds: string[] = []; for (const id of ids) { const result = await deleteDraft(id); if (!result.success) { failedIds.push(id); } } if (failedIds.length > 0) { return { success: false, failedIds, error: `${failedIds.length}건의 삭제에 실패했습니다.`, }; } return { success: true }; } /** * 결재 상신 */ export async function submitDraft(id: string): Promise<{ success: boolean; error?: string; __authError?: boolean }> { try { const { response, error } = await serverFetch( `${process.env.NEXT_PUBLIC_API_URL}/api/v1/approvals/${id}/submit`, { method: 'POST', body: JSON.stringify({}), } ); if (error?.__authError) { return { success: false, __authError: true }; } if (!response) { return { success: false, error: error?.message || '결재 상신에 실패했습니다.' }; } const result = await response.json(); if (!response.ok || !result.success) { return { success: false, error: result.message || '결재 상신에 실패했습니다.', }; } return { success: true }; } catch (error) { if (isNextRedirectError(error)) throw error; console.error('[DraftBoxActions] submitDraft error:', error); return { success: false, error: '서버 오류가 발생했습니다.', }; } } /** * 결재 문서 일괄 상신 */ export async function submitDrafts(ids: string[]): Promise<{ success: boolean; failedIds?: string[]; error?: string }> { const failedIds: string[] = []; for (const id of ids) { const result = await submitDraft(id); if (!result.success) { failedIds.push(id); } } if (failedIds.length > 0) { return { success: false, failedIds, error: `${failedIds.length}건의 상신에 실패했습니다.`, }; } return { success: true }; } /** * 결재 회수 (기안자만) */ export async function cancelDraft(id: string): Promise<{ success: boolean; error?: string; __authError?: boolean }> { try { const { response, error } = await serverFetch( `${process.env.NEXT_PUBLIC_API_URL}/api/v1/approvals/${id}/cancel`, { method: 'POST', body: JSON.stringify({}), } ); if (error?.__authError) { return { success: false, __authError: true }; } if (!response) { return { success: false, error: error?.message || '결재 회수에 실패했습니다.' }; } const result = await response.json(); if (!response.ok || !result.success) { return { success: false, error: result.message || '결재 회수에 실패했습니다.', }; } return { success: true }; } catch (error) { if (isNextRedirectError(error)) throw error; console.error('[DraftBoxActions] cancelDraft error:', error); return { success: false, error: '서버 오류가 발생했습니다.', }; } }