refactor(WEB): Server Action 공통화 및 보안 강화
- executeServerAction 공통 유틸 도입으로 actions.ts 대폭 간소화 (50+개 파일) - sanitize 유틸 추가 (XSS 방지) - middleware CSP 헤더 추가 및 Open Redirect 방지 - 프록시 라우트 로깅 개발환경 한정으로 변경 - 프로덕션 불필요 console.log 제거 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -11,20 +11,13 @@
|
||||
'use server';
|
||||
|
||||
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
import { serverFetch } from '@/lib/api/fetch-wrapper';
|
||||
import { executeServerAction, type ActionResult } from '@/lib/api/execute-server-action';
|
||||
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[];
|
||||
@@ -40,24 +33,13 @@ interface InboxSummary {
|
||||
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 };
|
||||
};
|
||||
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;
|
||||
@@ -68,12 +50,7 @@ interface InboxStepApiData {
|
||||
step_order: number;
|
||||
step_type: string;
|
||||
approver_id: number;
|
||||
approver?: {
|
||||
id: number;
|
||||
name: string;
|
||||
position?: string;
|
||||
department?: { name: string };
|
||||
};
|
||||
approver?: { id: number; name: string; position?: string; department?: { name: string } };
|
||||
status: string;
|
||||
processed_at?: string;
|
||||
comment?: string;
|
||||
@@ -83,63 +60,35 @@ interface InboxStepApiData {
|
||||
// 헬퍼 함수
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* API 상태 → 프론트엔드 상태 변환
|
||||
*/
|
||||
function mapApiStatus(apiStatus: string): ApprovalStatus {
|
||||
const statusMap: Record<string, ApprovalStatus> = {
|
||||
'pending': 'pending',
|
||||
'approved': 'approved',
|
||||
'rejected': 'rejected',
|
||||
'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', // 반려 (동일)
|
||||
'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',
|
||||
'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': '반려',
|
||||
'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';
|
||||
@@ -163,242 +112,91 @@ function transformApiToFrontend(data: InboxApiData): ApprovalRecord {
|
||||
};
|
||||
}
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL;
|
||||
|
||||
// ============================================
|
||||
// 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';
|
||||
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 };
|
||||
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') {
|
||||
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 result = await executeServerAction<PaginatedResponse<InboxApiData>>({
|
||||
url: `${API_URL}/api/v1/approvals/inbox?${searchParams.toString()}`,
|
||||
errorMessage: '결재함 목록 조회에 실패했습니다.',
|
||||
});
|
||||
|
||||
if (result.__authError) return { data: [], total: 0, lastPage: 1, __authError: true };
|
||||
if (!result.success || !result.data?.data) return { data: [], total: 0, lastPage: 1 };
|
||||
|
||||
return {
|
||||
data: result.data.data.map(transformApiToFrontend),
|
||||
total: result.data.total,
|
||||
lastPage: result.data.last_page,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 결재함 통계 조회
|
||||
*/
|
||||
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;
|
||||
}
|
||||
const result = await executeServerAction<InboxSummary>({
|
||||
url: `${API_URL}/api/v1/approvals/inbox/summary`,
|
||||
errorMessage: '결재함 통계 조회에 실패했습니다.',
|
||||
});
|
||||
return result.success ? result.data || null : 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 approveDocument(id: string, comment?: string): Promise<ActionResult> {
|
||||
return executeServerAction({
|
||||
url: `${API_URL}/api/v1/approvals/${id}/approve`,
|
||||
method: 'POST',
|
||||
body: { comment: comment || '' },
|
||||
errorMessage: '승인 처리에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 반려 처리
|
||||
*/
|
||||
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 rejectDocument(id: string, comment: string): Promise<ActionResult> {
|
||||
if (!comment?.trim()) return { success: false, error: '반려 사유를 입력해주세요.' };
|
||||
return executeServerAction({
|
||||
url: `${API_URL}/api/v1/approvals/${id}/reject`,
|
||||
method: 'POST',
|
||||
body: { comment },
|
||||
errorMessage: '반려 처리에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 일괄 승인 처리
|
||||
*/
|
||||
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 (!result.success) failedIds.push(id);
|
||||
}
|
||||
|
||||
if (failedIds.length > 0) {
|
||||
return {
|
||||
success: false,
|
||||
failedIds,
|
||||
error: `${failedIds.length}건의 승인 처리에 실패했습니다.`,
|
||||
};
|
||||
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: '반려 사유를 입력해주세요.',
|
||||
};
|
||||
}
|
||||
|
||||
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 (!result.success) failedIds.push(id);
|
||||
}
|
||||
|
||||
if (failedIds.length > 0) {
|
||||
return {
|
||||
success: false,
|
||||
failedIds,
|
||||
error: `${failedIds.length}건의 반려 처리에 실패했습니다.`,
|
||||
};
|
||||
return { success: false, failedIds, error: `${failedIds.length}건의 반려 처리에 실패했습니다.` };
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
import { cookies } from 'next/headers';
|
||||
import { serverFetch } from '@/lib/api/fetch-wrapper';
|
||||
import { executeServerAction } from '@/lib/api/execute-server-action';
|
||||
import type {
|
||||
ExpenseEstimateItem,
|
||||
ApprovalPerson,
|
||||
@@ -125,6 +125,8 @@ function transformEmployee(employee: EmployeeApiData): ApprovalPerson {
|
||||
// API 함수
|
||||
// ============================================
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL;
|
||||
|
||||
/**
|
||||
* 파일 업로드
|
||||
* @param files 업로드할 파일 배열
|
||||
@@ -200,88 +202,36 @@ export async function getExpenseEstimateItems(yearMonth?: string): Promise<{
|
||||
accountBalance: number;
|
||||
finalDifference: number;
|
||||
} | null> {
|
||||
try {
|
||||
const searchParams = new URLSearchParams();
|
||||
const searchParams = new URLSearchParams();
|
||||
if (yearMonth) searchParams.set('year_month', yearMonth);
|
||||
|
||||
if (yearMonth) {
|
||||
searchParams.set('year_month', yearMonth);
|
||||
}
|
||||
|
||||
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/reports/expense-estimate?${searchParams.toString()}`;
|
||||
|
||||
const { response, error } = await serverFetch(url, {
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
if (error || !response) {
|
||||
console.error('[DocumentCreateActions] GET expense-estimate error:', error?.message);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('[DocumentCreateActions] GET expense-estimate error:', response.status);
|
||||
return null;
|
||||
}
|
||||
|
||||
const result: ApiResponse<ExpenseEstimateApiResponse> = await response.json();
|
||||
|
||||
if (!result.success || !result.data) {
|
||||
console.warn('[DocumentCreateActions] No data in response');
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
items: result.data.items.map(transformExpenseEstimateItem),
|
||||
totalExpense: result.data.total_expense,
|
||||
accountBalance: result.data.account_balance,
|
||||
finalDifference: result.data.final_difference,
|
||||
};
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('[DocumentCreateActions] getExpenseEstimateItems error:', error);
|
||||
return null;
|
||||
}
|
||||
const result = await executeServerAction<ExpenseEstimateApiResponse>({
|
||||
url: `${API_URL}/api/v1/reports/expense-estimate?${searchParams.toString()}`,
|
||||
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<ApprovalPerson[]> {
|
||||
try {
|
||||
const searchParams = new URLSearchParams();
|
||||
searchParams.set('per_page', '100');
|
||||
if (search) {
|
||||
searchParams.set('search', search);
|
||||
}
|
||||
const searchParams = new URLSearchParams();
|
||||
searchParams.set('per_page', '100');
|
||||
if (search) searchParams.set('search', search);
|
||||
|
||||
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/employees?${searchParams.toString()}`;
|
||||
|
||||
const { response, error } = await serverFetch(url, {
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
if (error || !response) {
|
||||
console.error('[DocumentCreateActions] GET employees error:', error?.message);
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('[DocumentCreateActions] GET employees error:', response.status);
|
||||
return [];
|
||||
}
|
||||
|
||||
const result: ApiResponse<{ data: EmployeeApiData[] }> = await response.json();
|
||||
|
||||
if (!result.success || !result.data?.data) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return result.data.data.map(transformEmployee);
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('[DocumentCreateActions] getEmployees error:', error);
|
||||
return [];
|
||||
}
|
||||
const result = await executeServerAction<{ data: EmployeeApiData[] }>({
|
||||
url: `${API_URL}/api/v1/employees?${searchParams.toString()}`,
|
||||
errorMessage: '직원 목록 조회에 실패했습니다.',
|
||||
});
|
||||
if (!result.success || !result.data?.data) return [];
|
||||
return result.data.data.map(transformEmployee);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -292,80 +242,47 @@ export async function createApproval(formData: DocumentFormData): Promise<{
|
||||
data?: { id: number; documentNo: string };
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
// 새 첨부파일 업로드
|
||||
const newFiles = formData.proposalData?.attachments
|
||||
|| formData.expenseReportData?.attachments
|
||||
|| [];
|
||||
let uploadedFiles: UploadedFile[] = [];
|
||||
// 새 첨부파일 업로드
|
||||
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 || [];
|
||||
if (newFiles.length > 0) {
|
||||
const uploadResult = await uploadFiles(newFiles);
|
||||
if (!uploadResult.success) {
|
||||
return { success: false, error: uploadResult.error };
|
||||
}
|
||||
|
||||
// 프론트엔드 데이터 → API 요청 데이터 변환
|
||||
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 { response, error } = await serverFetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/approvals`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify(requestBody),
|
||||
}
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
return {
|
||||
success: false,
|
||||
error: error?.message || '문서 저장에 실패했습니다.',
|
||||
};
|
||||
}
|
||||
|
||||
const result: ApiResponse<ApprovalCreateResponse> = await response.json();
|
||||
|
||||
if (!response.ok || !result.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: result.message || '문서 저장에 실패했습니다.',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
id: result.data.id,
|
||||
documentNo: result.data.document_number,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('[DocumentCreateActions] createApproval error:', error);
|
||||
return {
|
||||
success: false,
|
||||
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<ApprovalCreateResponse>({
|
||||
url: `${API_URL}/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 } };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -375,40 +292,13 @@ export async function submitApproval(id: number): Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const { response, error } = await serverFetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/approvals/${id}/submit`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({}),
|
||||
}
|
||||
);
|
||||
|
||||
if (error || !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('[DocumentCreateActions] submitApproval error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: '서버 오류가 발생했습니다.',
|
||||
};
|
||||
}
|
||||
const result = await executeServerAction({
|
||||
url: `${API_URL}/api/v1/approvals/${id}/submit`,
|
||||
method: 'POST',
|
||||
body: {},
|
||||
errorMessage: '문서 상신에 실패했습니다.',
|
||||
});
|
||||
return { success: result.success, error: result.error };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -457,41 +347,13 @@ export async function getApprovalById(id: number): Promise<{
|
||||
data?: DocumentFormData;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const { response, error } = await serverFetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/approvals/${id}`,
|
||||
{
|
||||
method: 'GET',
|
||||
}
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
return { success: false, error: error?.message || '문서 조회에 실패했습니다.' };
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
return { success: false, error: '문서를 찾을 수 없습니다.' };
|
||||
}
|
||||
return { success: false, error: '문서 조회에 실패했습니다.' };
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!result.success || !result.data) {
|
||||
return { success: false, error: result.message || '문서 조회에 실패했습니다.' };
|
||||
}
|
||||
|
||||
// API 응답을 프론트엔드 형식으로 변환
|
||||
const apiData = result.data;
|
||||
const formDataResult = transformApiToFormData(apiData);
|
||||
|
||||
return { success: true, data: formDataResult };
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('[DocumentCreateActions] getApprovalById error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const result = await executeServerAction<any>({
|
||||
url: `${API_URL}/api/v1/approvals/${id}`,
|
||||
errorMessage: '문서 조회에 실패했습니다.',
|
||||
});
|
||||
if (!result.success || !result.data) return { success: false, error: result.error };
|
||||
return { success: true, data: transformApiToFormData(result.data) };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -502,75 +364,46 @@ export async function updateApproval(id: number, formData: DocumentFormData): Pr
|
||||
data?: { id: number; documentNo: string };
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
// 새 첨부파일 업로드
|
||||
const newFiles = formData.proposalData?.attachments
|
||||
|| formData.expenseReportData?.attachments
|
||||
|| [];
|
||||
let uploadedFiles: UploadedFile[] = [];
|
||||
// 새 첨부파일 업로드
|
||||
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 || [];
|
||||
if (newFiles.length > 0) {
|
||||
const uploadResult = await uploadFiles(newFiles);
|
||||
if (!uploadResult.success) {
|
||||
return { success: false, error: uploadResult.error };
|
||||
}
|
||||
|
||||
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 { response, error } = await serverFetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/approvals/${id}`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(requestBody),
|
||||
}
|
||||
);
|
||||
|
||||
if (error || !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,
|
||||
data: {
|
||||
id: result.data.id,
|
||||
documentNo: result.data.document_number,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('[DocumentCreateActions] updateApproval error:', error);
|
||||
return { success: false, 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<ApprovalCreateResponse>({
|
||||
url: `${API_URL}/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 } };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -615,39 +448,12 @@ export async function deleteApproval(id: number): Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const { response, error } = await serverFetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/approvals/${id}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
}
|
||||
);
|
||||
|
||||
if (error || !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('[DocumentCreateActions] deleteApproval error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: '서버 오류가 발생했습니다.',
|
||||
};
|
||||
}
|
||||
const result = await executeServerAction({
|
||||
url: `${API_URL}/api/v1/approvals/${id}`,
|
||||
method: 'DELETE',
|
||||
errorMessage: '문서 삭제에 실패했습니다.',
|
||||
});
|
||||
return { success: result.success, error: result.error };
|
||||
}
|
||||
|
||||
// ============================================
|
||||
|
||||
@@ -13,20 +13,13 @@
|
||||
'use server';
|
||||
|
||||
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
import { serverFetch } from '@/lib/api/fetch-wrapper';
|
||||
import { executeServerAction, type ActionResult } from '@/lib/api/execute-server-action';
|
||||
import type { DraftRecord, DocumentStatus, Approver } from './types';
|
||||
|
||||
// ============================================
|
||||
// API 응답 타입 정의
|
||||
// ============================================
|
||||
|
||||
interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
data: T;
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface PaginatedResponse<T> {
|
||||
current_page: number;
|
||||
data: T[];
|
||||
@@ -166,295 +159,108 @@ function transformApiToFrontend(data: ApprovalApiData): DraftRecord {
|
||||
};
|
||||
}
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL;
|
||||
|
||||
// ============================================
|
||||
// API 함수
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* 기안함 목록 조회
|
||||
*/
|
||||
export async function getDrafts(params?: {
|
||||
page?: number;
|
||||
per_page?: number;
|
||||
search?: string;
|
||||
status?: string;
|
||||
sort_by?: string;
|
||||
sort_dir?: 'asc' | 'desc';
|
||||
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<string, string> = {
|
||||
'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<PaginatedResponse<ApprovalApiData>> = 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,
|
||||
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') {
|
||||
const statusMap: Record<string, string> = {
|
||||
'draft': 'draft', 'pending': 'pending', 'inProgress': 'in_progress',
|
||||
'approved': 'approved', 'rejected': 'rejected',
|
||||
};
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('[DraftBoxActions] getDrafts error:', error);
|
||||
return { data: [], total: 0, lastPage: 1 };
|
||||
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 result = await executeServerAction<PaginatedResponse<ApprovalApiData>>({
|
||||
url: `${API_URL}/api/v1/approvals/drafts?${searchParams.toString()}`,
|
||||
errorMessage: '기안함 목록 조회에 실패했습니다.',
|
||||
});
|
||||
|
||||
if (result.__authError) return { data: [], total: 0, lastPage: 1, __authError: true };
|
||||
if (!result.success || !result.data?.data) return { data: [], total: 0, lastPage: 1 };
|
||||
|
||||
return {
|
||||
data: result.data.data.map(transformApiToFrontend),
|
||||
total: result.data.total,
|
||||
lastPage: result.data.last_page,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 기안함 현황 카드 (통계)
|
||||
*/
|
||||
export async function getDraftsSummary(): Promise<DraftsSummary | null> {
|
||||
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<DraftsSummary> = 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;
|
||||
}
|
||||
const result = await executeServerAction<DraftsSummary>({
|
||||
url: `${API_URL}/api/v1/approvals/drafts/summary`,
|
||||
errorMessage: '기안함 현황 조회에 실패했습니다.',
|
||||
});
|
||||
return result.success ? result.data || null : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 결재 문서 상세 조회
|
||||
*/
|
||||
export async function getDraftById(id: string): Promise<DraftRecord | null> {
|
||||
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<ApprovalApiData> = 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;
|
||||
}
|
||||
const result = await executeServerAction({
|
||||
url: `${API_URL}/api/v1/approvals/${id}`,
|
||||
transform: (data: ApprovalApiData) => transformApiToFrontend(data),
|
||||
errorMessage: '결재 문서 조회에 실패했습니다.',
|
||||
});
|
||||
return result.success ? result.data || null : 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 deleteDraft(id: string): Promise<ActionResult> {
|
||||
return executeServerAction({
|
||||
url: `${API_URL}/api/v1/approvals/${id}`,
|
||||
method: 'DELETE',
|
||||
errorMessage: '결재 문서 삭제에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 결재 문서 일괄 삭제
|
||||
*/
|
||||
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 (!result.success) failedIds.push(id);
|
||||
}
|
||||
|
||||
if (failedIds.length > 0) {
|
||||
return {
|
||||
success: false,
|
||||
failedIds,
|
||||
error: `${failedIds.length}건의 삭제에 실패했습니다.`,
|
||||
};
|
||||
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 submitDraft(id: string): Promise<ActionResult> {
|
||||
return executeServerAction({
|
||||
url: `${API_URL}/api/v1/approvals/${id}/submit`,
|
||||
method: 'POST',
|
||||
body: {},
|
||||
errorMessage: '결재 상신에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 결재 문서 일괄 상신
|
||||
*/
|
||||
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 (!result.success) failedIds.push(id);
|
||||
}
|
||||
|
||||
if (failedIds.length > 0) {
|
||||
return {
|
||||
success: false,
|
||||
failedIds,
|
||||
error: `${failedIds.length}건의 상신에 실패했습니다.`,
|
||||
};
|
||||
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: '서버 오류가 발생했습니다.',
|
||||
};
|
||||
}
|
||||
export async function cancelDraft(id: string): Promise<ActionResult> {
|
||||
return executeServerAction({
|
||||
url: `${API_URL}/api/v1/approvals/${id}/cancel`,
|
||||
method: 'POST',
|
||||
body: {},
|
||||
errorMessage: '결재 회수에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -10,20 +10,14 @@
|
||||
'use server';
|
||||
|
||||
|
||||
import { executeServerAction, type ActionResult } from '@/lib/api/execute-server-action';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
import { serverFetch } from '@/lib/api/fetch-wrapper';
|
||||
import type { ReferenceRecord, ApprovalType, DocumentStatus } from './types';
|
||||
|
||||
// ============================================
|
||||
// API 응답 타입 정의
|
||||
// ============================================
|
||||
|
||||
interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
data: T;
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface PaginatedResponse<T> {
|
||||
current_page: number;
|
||||
data: T[];
|
||||
@@ -32,24 +26,13 @@ interface PaginatedResponse<T> {
|
||||
last_page: number;
|
||||
}
|
||||
|
||||
// API 응답의 참조 문서 타입
|
||||
interface ReferenceApiData {
|
||||
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 };
|
||||
};
|
||||
form?: { id: number; name: string; code: string; category: string };
|
||||
drafter?: { id: number; name: string; position?: string; department?: { name: string } };
|
||||
steps?: ReferenceStepApiData[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
@@ -60,12 +43,7 @@ interface ReferenceStepApiData {
|
||||
step_order: number;
|
||||
step_type: string;
|
||||
approver_id: number;
|
||||
approver?: {
|
||||
id: number;
|
||||
name: string;
|
||||
position?: string;
|
||||
department?: { name: string };
|
||||
};
|
||||
approver?: { id: number; name: string; position?: string; department?: { name: string } };
|
||||
is_read: boolean;
|
||||
read_at?: string;
|
||||
}
|
||||
@@ -74,37 +52,22 @@ interface ReferenceStepApiData {
|
||||
// 헬퍼 함수
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* API 상태 → 프론트엔드 상태 변환
|
||||
*/
|
||||
function mapApiStatus(apiStatus: string): DocumentStatus {
|
||||
const statusMap: Record<string, DocumentStatus> = {
|
||||
'draft': 'pending',
|
||||
'pending': 'pending',
|
||||
'in_progress': 'pending',
|
||||
'approved': 'approved',
|
||||
'rejected': 'rejected',
|
||||
'draft': 'pending', 'pending': 'pending', 'in_progress': 'pending',
|
||||
'approved': 'approved', 'rejected': 'rejected',
|
||||
};
|
||||
return statusMap[apiStatus] || 'pending';
|
||||
}
|
||||
|
||||
/**
|
||||
* 양식 카테고리 → 결재 유형 변환
|
||||
*/
|
||||
function mapApprovalType(formCategory?: string): ApprovalType {
|
||||
const typeMap: Record<string, ApprovalType> = {
|
||||
'expense_report': 'expense_report',
|
||||
'proposal': 'proposal',
|
||||
'expense_estimate': 'expense_estimate',
|
||||
'expense_report': 'expense_report', 'proposal': 'proposal', 'expense_estimate': 'expense_estimate',
|
||||
};
|
||||
return typeMap[formCategory || ''] || 'proposal';
|
||||
}
|
||||
|
||||
/**
|
||||
* API 데이터 → 프론트엔드 데이터 변환
|
||||
*/
|
||||
function transformApiToFrontend(data: ReferenceApiData): ReferenceRecord {
|
||||
// 참조 단계에서 열람 상태 추출
|
||||
const referenceStep = data.steps?.find(s => s.step_type === 'reference');
|
||||
const isRead = referenceStep?.is_read ?? false;
|
||||
const readAt = referenceStep?.read_at;
|
||||
@@ -126,219 +89,91 @@ function transformApiToFrontend(data: ReferenceApiData): ReferenceRecord {
|
||||
};
|
||||
}
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL;
|
||||
|
||||
// ============================================
|
||||
// API 함수
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* 참조함 목록 조회
|
||||
*/
|
||||
export async function getReferences(params?: {
|
||||
page?: number;
|
||||
per_page?: number;
|
||||
search?: string;
|
||||
is_read?: boolean;
|
||||
approval_type?: string;
|
||||
sort_by?: string;
|
||||
sort_dir?: 'asc' | 'desc';
|
||||
page?: number; per_page?: number; search?: string; is_read?: boolean;
|
||||
approval_type?: string; sort_by?: string; sort_dir?: 'asc' | 'desc';
|
||||
}): Promise<{ data: ReferenceRecord[]; total: number; lastPage: number }> {
|
||||
try {
|
||||
const searchParams = new URLSearchParams();
|
||||
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?.is_read !== undefined) searchParams.set('is_read', params.is_read ? '1' : '0');
|
||||
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);
|
||||
|
||||
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?.is_read !== undefined) {
|
||||
searchParams.set('is_read', params.is_read ? '1' : '0');
|
||||
}
|
||||
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 result = await executeServerAction<PaginatedResponse<ReferenceApiData>>({
|
||||
url: `${API_URL}/api/v1/approvals/reference?${searchParams.toString()}`,
|
||||
errorMessage: '참조 목록 조회에 실패했습니다.',
|
||||
});
|
||||
|
||||
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/approvals/reference?${searchParams.toString()}`;
|
||||
|
||||
const { response, error } = await serverFetch(url, {
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
// serverFetch handles 401 with redirect, so we just check for other errors
|
||||
if (error || !response) {
|
||||
console.error('[ReferenceBoxActions] GET reference error:', error?.message);
|
||||
return { data: [], total: 0, lastPage: 1 };
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('[ReferenceBoxActions] GET reference error:', response.status);
|
||||
return { data: [], total: 0, lastPage: 1 };
|
||||
}
|
||||
|
||||
const result: ApiResponse<PaginatedResponse<ReferenceApiData>> = await response.json();
|
||||
|
||||
if (!result.success || !result.data?.data) {
|
||||
console.warn('[ReferenceBoxActions] 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('[ReferenceBoxActions] getReferences error:', error);
|
||||
if (!result.success || !result.data?.data) {
|
||||
return { data: [], total: 0, lastPage: 1 };
|
||||
}
|
||||
|
||||
return {
|
||||
data: result.data.data.map(transformApiToFrontend),
|
||||
total: result.data.total,
|
||||
lastPage: result.data.last_page,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 참조함 통계 (목록 데이터 기반)
|
||||
*/
|
||||
export async function getReferenceSummary(): Promise<{ all: number; read: number; unread: number } | null> {
|
||||
try {
|
||||
// 전체 데이터를 조회해서 통계 계산
|
||||
const allResult = await getReferences({ per_page: 1 });
|
||||
const readResult = await getReferences({ per_page: 1, is_read: true });
|
||||
const unreadResult = await getReferences({ per_page: 1, is_read: false });
|
||||
|
||||
return {
|
||||
all: allResult.total,
|
||||
read: readResult.total,
|
||||
unread: unreadResult.total,
|
||||
};
|
||||
return { all: allResult.total, read: readResult.total, unread: unreadResult.total };
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('[ReferenceBoxActions] getReferenceSummary error:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 열람 처리
|
||||
*/
|
||||
export async function markAsRead(id: string): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/approvals/${id}/read`;
|
||||
|
||||
const { response, error } = await serverFetch(url, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
|
||||
// serverFetch handles 401 with redirect
|
||||
if (error || !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('[ReferenceBoxActions] markAsRead error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: '서버 오류가 발생했습니다.',
|
||||
};
|
||||
}
|
||||
export async function markAsRead(id: string): Promise<ActionResult> {
|
||||
return executeServerAction({
|
||||
url: `${API_URL}/api/v1/approvals/${id}/read`,
|
||||
method: 'POST',
|
||||
body: {},
|
||||
errorMessage: '열람 처리에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 미열람 처리
|
||||
*/
|
||||
export async function markAsUnread(id: string): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/approvals/${id}/unread`;
|
||||
|
||||
const { response, error } = await serverFetch(url, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
|
||||
// serverFetch handles 401 with redirect
|
||||
if (error || !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('[ReferenceBoxActions] markAsUnread error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: '서버 오류가 발생했습니다.',
|
||||
};
|
||||
}
|
||||
export async function markAsUnread(id: string): Promise<ActionResult> {
|
||||
return executeServerAction({
|
||||
url: `${API_URL}/api/v1/approvals/${id}/unread`,
|
||||
method: 'POST',
|
||||
body: {},
|
||||
errorMessage: '미열람 처리에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 일괄 열람 처리
|
||||
*/
|
||||
export async function markAsReadBulk(ids: string[]): Promise<{ success: boolean; failedIds?: string[]; error?: string }> {
|
||||
const failedIds: string[] = [];
|
||||
|
||||
for (const id of ids) {
|
||||
const result = await markAsRead(id);
|
||||
if (!result.success) {
|
||||
failedIds.push(id);
|
||||
}
|
||||
if (!result.success) failedIds.push(id);
|
||||
}
|
||||
|
||||
if (failedIds.length > 0) {
|
||||
return {
|
||||
success: false,
|
||||
failedIds,
|
||||
error: `${failedIds.length}건의 열람 처리에 실패했습니다.`,
|
||||
};
|
||||
return { success: false, failedIds, error: `${failedIds.length}건의 열람 처리에 실패했습니다.` };
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* 일괄 미열람 처리
|
||||
*/
|
||||
export async function markAsUnreadBulk(ids: string[]): Promise<{ success: boolean; failedIds?: string[]; error?: string }> {
|
||||
const failedIds: string[] = [];
|
||||
|
||||
for (const id of ids) {
|
||||
const result = await markAsUnread(id);
|
||||
if (!result.success) {
|
||||
failedIds.push(id);
|
||||
}
|
||||
if (!result.success) failedIds.push(id);
|
||||
}
|
||||
|
||||
if (failedIds.length > 0) {
|
||||
return {
|
||||
success: false,
|
||||
failedIds,
|
||||
error: `${failedIds.length}건의 미열람 처리에 실패했습니다.`,
|
||||
};
|
||||
return { success: false, failedIds, error: `${failedIds.length}건의 미열람 처리에 실패했습니다.` };
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
Reference in New Issue
Block a user