Files
sam-react-prod/src/components/approval/ApprovalBox/actions.ts
권혁성 c8890c1a85 feat(WEB): 결재함 문서 상세 모달 데이터 연동 개선
- ApprovalBox: 문서 클릭 시 API 데이터 로드하여 모달에 표시
- DocumentCreate: 품의서 폼 개선 및 actions 수정
- 결재자 정보 (직책, 부서) 표시 개선
2026-01-22 23:19:37 +09:00

404 lines
11 KiB
TypeScript

/**
* 결재함 서버 액션
*
* API Endpoints:
* - GET /api/v1/approvals/inbox - 결재함 목록 조회
* - GET /api/v1/approvals/inbox/summary - 결재함 통계
* - POST /api/v1/approvals/{id}/approve - 승인 처리
* - POST /api/v1/approvals/{id}/reject - 반려 처리
*/
'use server';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import { serverFetch } from '@/lib/api/fetch-wrapper';
import type { ApprovalRecord, ApprovalType, ApprovalStatus } from './types';
// ============================================
// API 응답 타입 정의
// ============================================
interface ApiResponse<T> {
success: boolean;
data: T;
message: string;
}
interface PaginatedResponse<T> {
current_page: number;
data: T[];
total: number;
per_page: number;
last_page: number;
}
interface InboxSummary {
total: number;
pending: number;
approved: number;
rejected: number;
}
// API 응답의 결재 문서 타입
interface InboxApiData {
id: number;
document_number: string;
title: string;
status: string;
form?: {
id: number;
name: string;
code: string;
category: string;
};
drafter?: {
id: number;
name: string;
position?: string;
department?: { name: string };
};
steps?: InboxStepApiData[];
created_at: string;
updated_at: string;
}
interface InboxStepApiData {
id: number;
step_order: number;
step_type: string;
approver_id: number;
approver?: {
id: number;
name: string;
position?: string;
department?: { name: string };
};
status: string;
processed_at?: string;
comment?: string;
}
// ============================================
// 헬퍼 함수
// ============================================
/**
* API 상태 → 프론트엔드 상태 변환
*/
function mapApiStatus(apiStatus: string): ApprovalStatus {
const statusMap: Record<string, ApprovalStatus> = {
'pending': 'pending',
'approved': 'approved',
'rejected': 'rejected',
};
return statusMap[apiStatus] || 'pending';
}
/**
* 프론트엔드 탭 상태 → 백엔드 API 상태 변환
* 백엔드 inbox API가 기대하는 값:
* - requested: 결재 요청 (현재 내 차례) = 미결재
* - completed: 내가 처리 완료 = 결재완료
* - rejected: 내가 반려한 문서 = 결재반려
*/
function mapTabToApiStatus(tabStatus: string): string | undefined {
const statusMap: Record<string, string> = {
'pending': 'requested', // 미결재 → 결재 요청
'approved': 'completed', // 결재완료 → 처리 완료
'rejected': 'rejected', // 반려 (동일)
};
return statusMap[tabStatus];
}
/**
* 양식 카테고리 → 결재 유형 변환
*/
function mapApprovalType(formCategory?: string): ApprovalType {
const typeMap: Record<string, ApprovalType> = {
'expense_report': 'expense_report',
'proposal': 'proposal',
'expense_estimate': 'expense_estimate',
};
return typeMap[formCategory || ''] || 'proposal';
}
/**
* 문서 상태 텍스트 변환
*/
function mapDocumentStatus(status: string): string {
const statusMap: Record<string, string> = {
'pending': '진행중',
'approved': '완료',
'rejected': '반려',
};
return statusMap[status] || '진행중';
}
/**
* API 데이터 → 프론트엔드 데이터 변환
*/
function transformApiToFrontend(data: InboxApiData): ApprovalRecord {
// 현재 사용자의 결재 단계 정보 추출 ('approval' 또는 'agreement' 타입)
const currentStep = data.steps?.find(s => s.step_type === 'approval' || s.step_type === 'agreement');
const approver = currentStep?.approver;
const stepStatus = currentStep?.status || 'pending';
return {
id: String(data.id),
documentNo: data.document_number,
approvalType: mapApprovalType(data.form?.category),
documentStatus: mapDocumentStatus(data.status),
title: data.title,
draftDate: data.created_at.replace('T', ' ').substring(0, 16),
drafter: data.drafter?.name || '',
drafterDepartment: data.drafter?.department?.name || '',
drafterPosition: data.drafter?.position || '',
approvalDate: currentStep?.processed_at?.replace('T', ' ').substring(0, 16),
approver: approver?.name,
status: mapApiStatus(stepStatus),
priority: 'normal',
createdAt: data.created_at,
updatedAt: data.updated_at,
};
}
// ============================================
// API 함수
// ============================================
/**
* 결재함 목록 조회
*/
export async function getInbox(params?: {
page?: number;
per_page?: number;
search?: string;
status?: string;
approval_type?: string;
sort_by?: string;
sort_dir?: 'asc' | 'desc';
}): Promise<{ data: ApprovalRecord[]; 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 apiStatus = mapTabToApiStatus(params.status);
if (apiStatus) {
searchParams.set('status', apiStatus);
}
}
if (params?.approval_type && params.approval_type !== 'all') {
searchParams.set('approval_type', params.approval_type);
}
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/inbox?${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('[ApprovalBoxActions] GET inbox error:', error?.message);
return { data: [], total: 0, lastPage: 1 };
}
const result: ApiResponse<PaginatedResponse<InboxApiData>> = await response.json();
if (!response.ok || !result.success || !result.data?.data) {
console.warn('[ApprovalBoxActions] 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('[ApprovalBoxActions] getInbox error:', error);
return { data: [], total: 0, lastPage: 1 };
}
}
/**
* 결재함 통계 조회
*/
export async function getInboxSummary(): Promise<InboxSummary | null> {
try {
const { response, error } = await serverFetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/approvals/inbox/summary`,
{ method: 'GET' }
);
if (error?.__authError || !response) {
console.error('[ApprovalBoxActions] GET inbox/summary error:', error?.message);
return null;
}
const result: ApiResponse<InboxSummary> = await response.json();
if (!response.ok || !result.success || !result.data) {
return null;
}
return result.data;
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[ApprovalBoxActions] getInboxSummary error:', error);
return null;
}
}
/**
* 승인 처리
*/
export async function approveDocument(id: string, comment?: string): Promise<{ success: boolean; error?: string; __authError?: boolean }> {
try {
const { response, error } = await serverFetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/approvals/${id}/approve`,
{
method: 'POST',
body: JSON.stringify({ comment: comment || '' }),
}
);
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('[ApprovalBoxActions] approveDocument error:', error);
return {
success: false,
error: '서버 오류가 발생했습니다.',
};
}
}
/**
* 반려 처리
*/
export async function rejectDocument(id: string, comment: string): Promise<{ success: boolean; error?: string; __authError?: boolean }> {
try {
if (!comment?.trim()) {
return {
success: false,
error: '반려 사유를 입력해주세요.',
};
}
const { response, error } = await serverFetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/approvals/${id}/reject`,
{
method: 'POST',
body: JSON.stringify({ comment }),
}
);
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('[ApprovalBoxActions] rejectDocument error:', error);
return {
success: false,
error: '서버 오류가 발생했습니다.',
};
}
}
/**
* 일괄 승인 처리
*/
export async function approveDocumentsBulk(ids: string[], comment?: string): Promise<{ success: boolean; failedIds?: string[]; error?: string }> {
const failedIds: string[] = [];
for (const id of ids) {
const result = await approveDocument(id, comment);
if (!result.success) {
failedIds.push(id);
}
}
if (failedIds.length > 0) {
return {
success: false,
failedIds,
error: `${failedIds.length}건의 승인 처리에 실패했습니다.`,
};
}
return { success: true };
}
/**
* 일괄 반려 처리
*/
export async function rejectDocumentsBulk(ids: string[], comment: string): Promise<{ success: boolean; failedIds?: string[]; error?: string }> {
if (!comment?.trim()) {
return {
success: false,
error: '반려 사유를 입력해주세요.',
};
}
const failedIds: string[] = [];
for (const id of ids) {
const result = await rejectDocument(id, comment);
if (!result.success) {
failedIds.push(id);
}
}
if (failedIds.length > 0) {
return {
success: false,
failedIds,
error: `${failedIds.length}건의 반려 처리에 실패했습니다.`,
};
}
return { success: true };
}